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:
5
go.mod
5
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
|
||||
|
||||
34
go.sum
34
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=
|
||||
|
||||
192
grpcserver/server.go
Normal file
192
grpcserver/server.go
Normal 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
239
grpcserver/server_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user