Update mcdsl from v1.0.0 to the port-env-support branch tip, which adds automatic $PORT environment variable support to the config package. Adapt grpcserver.New call to the updated signature that now accepts an *Options parameter (pass nil for default behavior). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
217 lines
6.7 KiB
Go
217 lines
6.7 KiB
Go
// Package grpcserver provides gRPC server setup with TLS, interceptor
|
|
// chain, and method-map authentication for Metacircular services.
|
|
//
|
|
// Access control is enforced via a [MethodMap] that classifies each RPC
|
|
// as public, auth-required, or admin-required. Methods not listed in any
|
|
// map are denied by default — forgetting to register a new RPC results
|
|
// in a denied request, not an open one.
|
|
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"git.wntrmute.dev/kyle/mcdsl/auth"
|
|
)
|
|
|
|
// MethodMap classifies gRPC methods for access control.
|
|
type MethodMap struct {
|
|
// Public methods require no authentication.
|
|
Public map[string]bool
|
|
|
|
// AuthRequired methods require a valid MCIAS bearer token.
|
|
AuthRequired map[string]bool
|
|
|
|
// AdminRequired methods require a valid token with the admin role.
|
|
AdminRequired map[string]bool
|
|
}
|
|
|
|
// Server wraps a grpc.Server with Metacircular auth interceptors.
|
|
type Server struct {
|
|
// GRPCServer is the underlying grpc.Server. Services register their
|
|
// implementations on it before calling Serve.
|
|
GRPCServer *grpc.Server
|
|
|
|
// Logger is used by the logging interceptor.
|
|
Logger *slog.Logger
|
|
|
|
listener net.Listener
|
|
}
|
|
|
|
// Options configures optional behavior for the gRPC server.
|
|
type Options struct {
|
|
// PreInterceptors run before the logging and auth interceptors.
|
|
// Use for lifecycle gates like seal checks that should reject
|
|
// requests before any auth validation occurs.
|
|
PreInterceptors []grpc.UnaryServerInterceptor
|
|
|
|
// PostInterceptors run after auth but before the handler.
|
|
// Use for audit logging, rate limiting, or other cross-cutting
|
|
// concerns that need access to the authenticated identity.
|
|
PostInterceptors []grpc.UnaryServerInterceptor
|
|
}
|
|
|
|
// New creates a gRPC server with TLS (if certFile and keyFile are
|
|
// non-empty) and an interceptor chain:
|
|
//
|
|
// [pre-interceptors] → logging → auth → [post-interceptors] → handler
|
|
//
|
|
// The auth interceptor uses methods to determine the access level for
|
|
// each RPC. Methods not in any map are denied by default.
|
|
//
|
|
// If certFile and keyFile are empty, TLS is skipped (for testing).
|
|
// opts is optional; pass nil for the default chain (logging + auth only).
|
|
func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger, opts *Options) (*Server, error) {
|
|
var interceptors []grpc.UnaryServerInterceptor
|
|
if opts != nil {
|
|
interceptors = append(interceptors, opts.PreInterceptors...)
|
|
}
|
|
interceptors = append(interceptors,
|
|
loggingInterceptor(logger),
|
|
authInterceptor(authenticator, methods),
|
|
)
|
|
if opts != nil {
|
|
interceptors = append(interceptors, opts.PostInterceptors...)
|
|
}
|
|
chain := grpc.ChainUnaryInterceptor(interceptors...)
|
|
|
|
var serverOpts []grpc.ServerOption
|
|
serverOpts = append(serverOpts, chain)
|
|
|
|
if certFile != "" && keyFile != "" {
|
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("grpcserver: load TLS cert: %w", err)
|
|
}
|
|
tlsCfg := &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: tls.VersionTLS13,
|
|
}
|
|
serverOpts = append(serverOpts, grpc.Creds(credentials.NewTLS(tlsCfg)))
|
|
}
|
|
|
|
return &Server{
|
|
GRPCServer: grpc.NewServer(serverOpts...),
|
|
Logger: logger,
|
|
}, nil
|
|
}
|
|
|
|
// Serve starts the gRPC server on the given address. It blocks until
|
|
// the server is stopped.
|
|
func (s *Server) Serve(addr string) error {
|
|
lis, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("grpcserver: listen %s: %w", addr, err)
|
|
}
|
|
s.listener = lis
|
|
s.Logger.Info("starting gRPC server", "addr", addr)
|
|
return s.GRPCServer.Serve(lis)
|
|
}
|
|
|
|
// Stop gracefully stops the gRPC server, waiting for in-flight RPCs
|
|
// to complete.
|
|
func (s *Server) Stop() {
|
|
s.GRPCServer.GracefulStop()
|
|
}
|
|
|
|
// TokenInfoFromContext extracts [auth.TokenInfo] from a gRPC request
|
|
// context. Returns nil if no token info is present (e.g., for public
|
|
// methods).
|
|
func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo {
|
|
return auth.TokenInfoFromContext(ctx)
|
|
}
|
|
|
|
// loggingInterceptor logs each RPC after it completes.
|
|
func loggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
|
|
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
|
start := time.Now()
|
|
resp, err := handler(ctx, req)
|
|
code := status.Code(err)
|
|
logger.Info("grpc",
|
|
"method", info.FullMethod,
|
|
"code", code.String(),
|
|
"duration", time.Since(start),
|
|
)
|
|
return resp, err
|
|
}
|
|
}
|
|
|
|
// authInterceptor enforces access control based on the MethodMap.
|
|
//
|
|
// Evaluation order:
|
|
// 1. Public → pass through, no auth.
|
|
// 2. AdminRequired → validate token, require IsAdmin.
|
|
// 3. AuthRequired → validate token.
|
|
// 4. Not in any map → deny (default deny).
|
|
func authInterceptor(authenticator *auth.Authenticator, methods MethodMap) grpc.UnaryServerInterceptor {
|
|
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
|
method := info.FullMethod
|
|
|
|
// Public methods: no auth.
|
|
if methods.Public[method] {
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
// All other methods require a valid token.
|
|
tokenInfo, err := extractAndValidate(ctx, authenticator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Admin-required methods: check admin role.
|
|
if methods.AdminRequired[method] {
|
|
if !tokenInfo.IsAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "admin role required")
|
|
}
|
|
ctx = auth.ContextWithTokenInfo(ctx, tokenInfo)
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
// Auth-required methods: token is sufficient.
|
|
if methods.AuthRequired[method] {
|
|
ctx = auth.ContextWithTokenInfo(ctx, tokenInfo)
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
// Default deny: method not in any map.
|
|
return nil, status.Errorf(codes.PermissionDenied, "method not authorized")
|
|
}
|
|
}
|
|
|
|
// extractAndValidate extracts the bearer token from gRPC metadata and
|
|
// validates it via the Authenticator.
|
|
func extractAndValidate(ctx context.Context, authenticator *auth.Authenticator) (*auth.TokenInfo, error) {
|
|
md, ok := metadata.FromIncomingContext(ctx)
|
|
if !ok {
|
|
return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
|
|
}
|
|
|
|
vals := md.Get("authorization")
|
|
if len(vals) == 0 {
|
|
return nil, status.Errorf(codes.Unauthenticated, "missing authorization header")
|
|
}
|
|
|
|
token := vals[0]
|
|
const bearerPrefix = "Bearer "
|
|
if len(token) > len(bearerPrefix) && token[:len(bearerPrefix)] == bearerPrefix {
|
|
token = token[len(bearerPrefix):]
|
|
}
|
|
|
|
info, err := authenticator.ValidateToken(token)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
|
|
}
|
|
|
|
return info, nil
|
|
}
|