diff --git a/go.mod b/go.mod index 6eca3c6..d070d8c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/pelletier/go-toml/v2 v2.3.0 + google.golang.org/grpc v1.79.3 modernc.org/sqlite v1.47.0 ) @@ -14,7 +15,11 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.10 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 92ddb1f..fd53a93 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,17 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -16,15 +26,39 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4 github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/grpcserver/server.go b/grpcserver/server.go new file mode 100644 index 0000000..2848cae --- /dev/null +++ b/grpcserver/server.go @@ -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 +} diff --git a/grpcserver/server_test.go b/grpcserver/server_test.go new file mode 100644 index 0000000..a843045 --- /dev/null +++ b/grpcserver/server_test.go @@ -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") + } +}