Add grpcserver package: gRPC server with method-map auth

- MethodMap with Public, AuthRequired, AdminRequired maps
- Default deny for unmapped methods (safety net)
- Auth interceptor: extracts Bearer token from metadata,
  validates via Authenticator, sets TokenInfo in context
- Logging interceptor: method, code, duration
- TLS 1.3 optional (skipped for testing)
- TokenInfoFromContext helper
- 10 tests with mock MCIAS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:33:02 -07:00
parent aba90a1de5
commit aa608b7efd
4 changed files with 470 additions and 0 deletions

192
grpcserver/server.go Normal file
View File

@@ -0,0 +1,192 @@
// 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
}

239
grpcserver/server_test.go Normal file
View File

@@ -0,0 +1,239 @@
package grpcserver
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/mcdsl/auth"
)
func mockMCIAS(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad", http.StatusBadRequest)
return
}
switch req.Token {
case "admin-token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true, "username": "admin", "roles": []string{"admin", "user"},
})
case "user-token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true, "username": "alice", "roles": []string{"user"},
})
default:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false})
}
})
return httptest.NewServer(mux)
}
func testAuth(t *testing.T, serverURL string) *auth.Authenticator {
t.Helper()
a, err := auth.New(auth.Config{ServerURL: serverURL}, slog.Default())
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return a
}
var testMethods = MethodMap{
Public: map[string]bool{"/test.Service/Health": true},
AuthRequired: map[string]bool{"/test.Service/List": true},
AdminRequired: map[string]bool{"/test.Service/Delete": true},
}
// callInterceptor simulates calling a gRPC interceptor with the given
// method and authorization metadata.
func callInterceptor(interceptor grpc.UnaryServerInterceptor, method, authHeader string) (any, error) {
ctx := context.Background()
if authHeader != "" {
md := metadata.Pairs("authorization", authHeader)
ctx = metadata.NewIncomingContext(ctx, md)
}
info := &grpc.UnaryServerInfo{FullMethod: method}
handler := func(ctx context.Context, _ any) (any, error) {
// Return the TokenInfo from context to verify it was set.
return auth.TokenInfoFromContext(ctx), nil
}
return interceptor(ctx, nil, info, handler)
}
func TestPublicMethodNoAuth(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
resp, err := callInterceptor(interceptor, "/test.Service/Health", "")
if err != nil {
t.Fatalf("public method error: %v", err)
}
// Public methods don't set TokenInfo.
info, _ := resp.(*auth.TokenInfo)
if info != nil {
t.Fatal("expected nil TokenInfo for public method")
}
}
func TestAuthRequiredWithValidToken(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
resp, err := callInterceptor(interceptor, "/test.Service/List", "Bearer user-token")
if err != nil {
t.Fatalf("auth method error: %v", err)
}
info, ok := resp.(*auth.TokenInfo)
if !ok || info == nil {
t.Fatal("expected TokenInfo in context")
}
if info.Username != "alice" {
t.Fatalf("Username = %q, want %q", info.Username, "alice")
}
}
func TestAuthRequiredWithoutToken(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
_, err := callInterceptor(interceptor, "/test.Service/List", "")
if err == nil {
t.Fatal("expected error for missing token")
}
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want Unauthenticated", status.Code(err))
}
}
func TestAuthRequiredWithInvalidToken(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
_, err := callInterceptor(interceptor, "/test.Service/List", "Bearer bad-token")
if err == nil {
t.Fatal("expected error for invalid token")
}
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("code = %v, want Unauthenticated", status.Code(err))
}
}
func TestAdminRequiredWithAdminToken(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
resp, err := callInterceptor(interceptor, "/test.Service/Delete", "Bearer admin-token")
if err != nil {
t.Fatalf("admin method error: %v", err)
}
info, ok := resp.(*auth.TokenInfo)
if !ok || info == nil {
t.Fatal("expected TokenInfo in context")
}
if !info.IsAdmin {
t.Fatal("expected IsAdmin=true")
}
}
func TestAdminRequiredWithUserToken(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
_, err := callInterceptor(interceptor, "/test.Service/Delete", "Bearer user-token")
if err == nil {
t.Fatal("expected error for non-admin on admin method")
}
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", status.Code(err))
}
}
func TestUnmappedMethodDenied(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
interceptor := authInterceptor(a, testMethods)
_, err := callInterceptor(interceptor, "/test.Service/Unknown", "Bearer admin-token")
if err == nil {
t.Fatal("expected error for unmapped method")
}
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", status.Code(err))
}
}
func TestLoggingInterceptor(t *testing.T) {
interceptor := loggingInterceptor(slog.Default())
info := &grpc.UnaryServerInfo{FullMethod: "/test.Service/Ping"}
handler := func(_ context.Context, _ any) (any, error) {
return "pong", nil
}
resp, err := interceptor(context.Background(), nil, info, handler)
if err != nil {
t.Fatalf("logging interceptor error: %v", err)
}
if resp != "pong" {
t.Fatalf("resp = %v, want pong", resp)
}
}
func TestNewWithoutTLS(t *testing.T) {
srv := mockMCIAS(t)
defer srv.Close()
a := testAuth(t, srv.URL)
s, err := New("", "", a, testMethods, slog.Default())
if err != nil {
t.Fatalf("New: %v", err)
}
if s.GRPCServer == nil {
t.Fatal("GRPCServer is nil")
}
}
func TestTokenInfoFromContext(t *testing.T) {
info := &auth.TokenInfo{Username: "test", IsAdmin: true}
ctx := auth.ContextWithTokenInfo(context.Background(), info)
got := TokenInfoFromContext(ctx)
if got == nil {
t.Fatal("nil from context")
}
if got.Username != "test" {
t.Fatalf("Username = %q, want %q", got.Username, "test")
}
}