Files
mcr/vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go
Kyle Isom 0838bcbab2 Bump mcdsl to f94c4b1 for $PORT env var support
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>
2026-03-27 01:07:52 -07:00

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
}