// 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 } // New creates a gRPC server with TLS (if certFile and keyFile are // non-empty) and an interceptor chain: logging → auth → 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). func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger) (*Server, error) { chain := grpc.ChainUnaryInterceptor( loggingInterceptor(logger), authInterceptor(authenticator, methods), ) var opts []grpc.ServerOption opts = append(opts, 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, } opts = append(opts, grpc.Creds(credentials.NewTLS(tlsCfg))) } return &Server{ GRPCServer: grpc.NewServer(opts...), 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 }