From 310ed83f28f254e5b3f2c82de2f7ea53badbcf51 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 26 Mar 2026 14:42:41 -0700 Subject: [PATCH] Migrate gRPC server to mcdsl grpcserver package Replace metacrypt's hand-rolled gRPC interceptor chain with the mcdsl grpcserver package, which provides TLS setup, logging, and method-map auth (public/auth-required/admin-required) out of the box. Metacrypt-specific interceptors are preserved as hooks: - sealInterceptor runs as a PreInterceptor (before logging/auth) - auditInterceptor runs as a PostInterceptor (after auth) The three legacy method maps (seal/auth/admin) are restructured into mcdsl's MethodMap (Public/AuthRequired/AdminRequired) plus a separate seal-required map for the PreInterceptor. Token context is now stored via mcdsl/auth.ContextWithTokenInfo instead of a package-local key. Bumps mcdsl from v1.0.0 to v1.0.1 (adds PreInterceptors/PostInterceptors to grpcserver.Options). Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- go.sum | 2 + internal/grpcserver/acme.go | 3 +- internal/grpcserver/auth.go | 2 +- internal/grpcserver/ca.go | 10 +- internal/grpcserver/engine.go | 5 +- internal/grpcserver/grpcserver_test.go | 155 ++++------- internal/grpcserver/interceptors.go | 97 +------ internal/grpcserver/server.go | 357 ++++++++++++++----------- internal/grpcserver/sshca.go | 3 +- internal/grpcserver/transit.go | 3 +- internal/grpcserver/user.go | 3 +- 12 files changed, 264 insertions(+), 378 deletions(-) diff --git a/go.mod b/go.mod index e48ed22..62b0a05 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.7 require ( git.wntrmute.dev/kyle/goutils v1.21.0 - git.wntrmute.dev/kyle/mcdsl v1.0.0 + git.wntrmute.dev/kyle/mcdsl v1.0.1 github.com/go-chi/chi/v5 v5.2.5 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 151a2f5..d213c81 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.wntrmute.dev/kyle/goutils v1.21.0 h1:ZR7ovV400hsF09zc8tkdHs6vyen8TDJ7flong/d git.wntrmute.dev/kyle/goutils v1.21.0/go.mod h1:JQ8NL5lHSEYl719UMf20p4G1ei70RVGma0hjjNXCR2c= git.wntrmute.dev/kyle/mcdsl v1.0.0 h1:YB7dx4gdNYKKcVySpL6UkwHqdCJ9Nl1yS0+eHk0hNtk= git.wntrmute.dev/kyle/mcdsl v1.0.0/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg= +git.wntrmute.dev/kyle/mcdsl v1.0.1 h1:Dr9Ud8cjWWybulpv+KsuSKbuZmzBXPCItQztR7o2hcA= +git.wntrmute.dev/kyle/mcdsl v1.0.1/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg= 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/grpcserver/acme.go b/internal/grpcserver/acme.go index 1afcc5a..afad07c 100644 --- a/internal/grpcserver/acme.go +++ b/internal/grpcserver/acme.go @@ -10,6 +10,7 @@ import ( pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" ) @@ -19,7 +20,7 @@ type acmeServer struct { } func (as *acmeServer) CreateEAB(ctx context.Context, req *pb.CreateEABRequest) (*pb.CreateEABResponse, error) { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) h, err := as.getOrCreateHandler(req.Mount) if err != nil { return nil, status.Error(codes.NotFound, "mount not found") diff --git a/internal/grpcserver/auth.go b/internal/grpcserver/auth.go index c37e83d..e1deb0c 100644 --- a/internal/grpcserver/auth.go +++ b/internal/grpcserver/auth.go @@ -37,7 +37,7 @@ func (as *authServer) Logout(ctx context.Context, _ *pb.LogoutRequest) (*pb.Logo } func (as *authServer) TokenInfo(ctx context.Context, _ *pb.TokenInfoRequest) (*pb.TokenInfoResponse, error) { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) if ti == nil { // Shouldn't happen — authInterceptor runs first — but guard anyway. token := extractToken(ctx) diff --git a/internal/grpcserver/ca.go b/internal/grpcserver/ca.go index b2f679b..da46f7e 100644 --- a/internal/grpcserver/ca.go +++ b/internal/grpcserver/ca.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" @@ -51,16 +52,9 @@ func (cs *caServer) caHandleRequest(ctx context.Context, mount, operation string return resp, nil } -func callerUsername(ctx context.Context) string { - ti := tokenInfoFromContext(ctx) - if ti == nil { - return "" - } - return ti.Username -} func (cs *caServer) callerInfo(ctx context.Context) *engine.CallerInfo { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) if ti == nil { return nil } diff --git a/internal/grpcserver/engine.go b/internal/grpcserver/engine.go index ec0e5a1..5b4b5f3 100644 --- a/internal/grpcserver/engine.go +++ b/internal/grpcserver/engine.go @@ -8,6 +8,7 @@ import ( "google.golang.org/grpc/status" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" ) @@ -48,7 +49,7 @@ func (es *engineServer) Mount(ctx context.Context, req *pb.MountRequest) (*pb.Mo return nil, status.Error(codes.Internal, err.Error()) } } - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) username := "" if ti != nil { username = ti.Username @@ -67,7 +68,7 @@ func (es *engineServer) Unmount(ctx context.Context, req *pb.UnmountRequest) (*p } return nil, status.Error(codes.Internal, err.Error()) } - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) username := "" if ti != nil { username = ti.Username diff --git a/internal/grpcserver/grpcserver_test.go b/internal/grpcserver/grpcserver_test.go index b704d13..36f4609 100644 --- a/internal/grpcserver/grpcserver_test.go +++ b/internal/grpcserver/grpcserver_test.go @@ -158,114 +158,6 @@ func TestSealInterceptor_SkipsUnlistedMethod(t *testing.T) { } } -func TestAuthInterceptor_MissingToken(t *testing.T) { - authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default()) - methods := map[string]bool{"/test.Service/Method": true} - interceptor := authInterceptor(authenticator, slog.Default(), methods) - - _, err := interceptor(context.Background(), nil, methodInfo("/test.Service/Method"), okHandler) - if err == nil { - t.Fatal("expected error for missing token") - } - if code := status.Code(err); code != codes.Unauthenticated { - t.Errorf("expected Unauthenticated, got %v", code) - } -} - -func TestAuthInterceptor_SkipsUnlistedMethod(t *testing.T) { - authenticator, _ := auth.NewAuthenticator(auth.Config{ServerURL: "http://localhost:0"}, slog.Default()) - methods := map[string]bool{"/test.Service/Other": true} - interceptor := authInterceptor(authenticator, slog.Default(), methods) - - resp, err := interceptor(context.Background(), nil, methodInfo("/test.Service/Method"), okHandler) - if err != nil { - t.Fatalf("expected pass-through, got: %v", err) - } - if resp != "ok" { - t.Errorf("expected 'ok', got %v", resp) - } -} - -func TestAdminInterceptor_NoTokenInfo(t *testing.T) { - methods := map[string]bool{"/test.Service/Admin": true} - interceptor := adminInterceptor(slog.Default(), methods) - - _, err := interceptor(context.Background(), nil, methodInfo("/test.Service/Admin"), okHandler) - if err == nil { - t.Fatal("expected error when no token info in context") - } - if code := status.Code(err); code != codes.PermissionDenied { - t.Errorf("expected PermissionDenied, got %v", code) - } -} - -func TestAdminInterceptor_NonAdmin(t *testing.T) { - methods := map[string]bool{"/test.Service/Admin": true} - interceptor := adminInterceptor(slog.Default(), methods) - - ctx := context.WithValue(context.Background(), tokenInfoKey, &auth.TokenInfo{ - Username: "user", - IsAdmin: false, - }) - _, err := interceptor(ctx, nil, methodInfo("/test.Service/Admin"), okHandler) - if err == nil { - t.Fatal("expected error for non-admin") - } - if code := status.Code(err); code != codes.PermissionDenied { - t.Errorf("expected PermissionDenied, got %v", code) - } -} - -func TestAdminInterceptor_Admin(t *testing.T) { - methods := map[string]bool{"/test.Service/Admin": true} - interceptor := adminInterceptor(slog.Default(), methods) - - ctx := context.WithValue(context.Background(), tokenInfoKey, &auth.TokenInfo{ - Username: "admin", - IsAdmin: true, - }) - resp, err := interceptor(ctx, nil, methodInfo("/test.Service/Admin"), okHandler) - if err != nil { - t.Fatalf("expected success for admin, got: %v", err) - } - if resp != "ok" { - t.Errorf("expected 'ok', got %v", resp) - } -} - -func TestAdminInterceptor_SkipsUnlistedMethod(t *testing.T) { - methods := map[string]bool{"/test.Service/Other": true} - interceptor := adminInterceptor(slog.Default(), methods) - - // No token info in context — but method not listed, so should pass through. - resp, err := interceptor(context.Background(), nil, methodInfo("/test.Service/Method"), okHandler) - if err != nil { - t.Fatalf("expected pass-through, got: %v", err) - } - if resp != "ok" { - t.Errorf("expected 'ok', got %v", resp) - } -} - -func TestChainInterceptors(t *testing.T) { - var order []int - makeInterceptor := func(n int) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - order = append(order, n) - return handler(ctx, req) - } - } - - chained := chainInterceptors(makeInterceptor(1), makeInterceptor(2), makeInterceptor(3)) - _, err := chained(context.Background(), nil, methodInfo("/test/Method"), okHandler) - if err != nil { - t.Fatalf("chain: %v", err) - } - if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 3 { - t.Errorf("expected execution order [1 2 3], got %v", order) - } -} - func TestExtractToken(t *testing.T) { tests := []struct { name string @@ -293,6 +185,21 @@ func TestExtractToken(t *testing.T) { } } +func TestCallerUsername(t *testing.T) { + // No token info in context. + if got := callerUsername(context.Background()); got != "" { + t.Errorf("expected empty, got %q", got) + } + + // With token info. + ctx := auth.ContextWithTokenInfo(context.Background(), &auth.TokenInfo{ + Username: "alice", + }) + if got := callerUsername(ctx); got != "alice" { + t.Errorf("expected 'alice', got %q", got) + } +} + // ---- systemServer tests ---- func TestSystemStatus(t *testing.T) { @@ -668,7 +575,7 @@ func TestAuthTokenInfo_FromContext(t *testing.T) { as := &authServer{s: srv} ti := &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} - ctx := context.WithValue(context.Background(), tokenInfoKey, ti) + ctx := auth.ContextWithTokenInfo(context.Background(), ti) resp, err := as.TokenInfo(ctx, &pb.TokenInfoRequest{}) if err != nil { @@ -718,3 +625,33 @@ func TestPbToRuleRoundtrip(t *testing.T) { t.Errorf("roundtrip Effect: got %q, want %q", back.Effect, original.Effect) } } + +// ---- method map tests ---- + +func TestMethodMapCompleteness(t *testing.T) { + // Verify every method in sealRequired is also in one of the auth maps + // (public, authRequired, or adminRequired). + mm := methodMap() + seal := sealRequiredMethods() + + for method := range seal { + if !mm.Public[method] && !mm.AuthRequired[method] && !mm.AdminRequired[method] { + t.Errorf("seal-required method %s is not in any auth map (public/auth/admin)", method) + } + } + + // Verify public/auth/admin maps don't overlap. + for method := range mm.Public { + if mm.AuthRequired[method] { + t.Errorf("method %s is in both Public and AuthRequired", method) + } + if mm.AdminRequired[method] { + t.Errorf("method %s is in both Public and AdminRequired", method) + } + } + for method := range mm.AuthRequired { + if mm.AdminRequired[method] { + t.Errorf("method %s is in both AuthRequired and AdminRequired", method) + } + } +} diff --git a/internal/grpcserver/interceptors.go b/internal/grpcserver/interceptors.go index 66135d5..ff358cd 100644 --- a/internal/grpcserver/interceptors.go +++ b/internal/grpcserver/interceptors.go @@ -4,11 +4,9 @@ import ( "context" "log/slog" "path" - "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "git.wntrmute.dev/kyle/metacrypt/internal/audit" @@ -16,61 +14,9 @@ import ( "git.wntrmute.dev/kyle/metacrypt/internal/seal" ) -type contextKey int - -const tokenInfoKey contextKey = iota - -func tokenInfoFromContext(ctx context.Context) *auth.TokenInfo { - v, _ := ctx.Value(tokenInfoKey).(*auth.TokenInfo) - return v -} - -// authInterceptor validates the Bearer token from gRPC metadata and injects -// *auth.TokenInfo into the context. The set of method full names that require -// auth is passed in; all others pass through without validation. -func authInterceptor(authenticator *auth.Authenticator, logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - if !methods[info.FullMethod] { - return handler(ctx, req) - } - - token := extractToken(ctx) - if token == "" { - logger.Debug("grpc request rejected: missing token", "method", info.FullMethod) - return nil, status.Error(codes.Unauthenticated, "missing authorization token") - } - - tokenInfo, err := authenticator.ValidateToken(token) - if err != nil { - logger.Debug("grpc request rejected: invalid token", "method", info.FullMethod, "error", err) - return nil, status.Error(codes.Unauthenticated, "invalid token") - } - - logger.Debug("grpc request authenticated", "method", info.FullMethod, "username", tokenInfo.Username) - ctx = context.WithValue(ctx, tokenInfoKey, tokenInfo) - return handler(ctx, req) - } -} - -// adminInterceptor requires IsAdmin on the token info for the listed methods. -// Must run after authInterceptor. -func adminInterceptor(logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - if !methods[info.FullMethod] { - return handler(ctx, req) - } - ti := tokenInfoFromContext(ctx) - if ti == nil || !ti.IsAdmin { - logger.Debug("grpc request rejected: admin required", "method", info.FullMethod) - return nil, status.Error(codes.PermissionDenied, "admin required") - } - logger.Debug("grpc admin request authorized", "method", info.FullMethod, "username", ti.Username) - return handler(ctx, req) - } -} - // sealInterceptor rejects calls with FailedPrecondition when the vault is -// sealed, for the listed methods. +// sealed, for the listed methods. It is intended to run as a PreInterceptor +// in the mcdsl grpcserver chain, before logging and auth. func sealInterceptor(sealMgr *seal.Manager, logger *slog.Logger, methods map[string]bool) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if !methods[info.FullMethod] { @@ -84,30 +30,17 @@ func sealInterceptor(sealMgr *seal.Manager, logger *slog.Logger, methods map[str } } -// chainInterceptors reduces a slice of interceptors into a single one. -func chainInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - chain := handler - for i := len(interceptors) - 1; i >= 0; i-- { - next := chain - ic := interceptors[i] - chain = func(ctx context.Context, req interface{}) (interface{}, error) { - return ic(ctx, req, info, next) - } - } - return chain(ctx, req) - } -} - -// auditInterceptor logs an audit event after each RPC completes. Must run -// after authInterceptor so that caller info is available in the context. +// auditInterceptor logs an audit event after each RPC completes. It is +// intended to run as a PostInterceptor in the mcdsl grpcserver chain, +// after auth so that caller info is available in the context via +// auth.TokenInfoFromContext. func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { resp, err := handler(ctx, req) caller := "anonymous" var roles []string - if ti := tokenInfoFromContext(ctx); ti != nil { + if ti := auth.TokenInfoFromContext(ctx); ti != nil { caller = ti.Username roles = ti.Roles } @@ -138,19 +71,3 @@ func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor { return resp, err } } - -func extractToken(ctx context.Context) string { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return "" - } - vals := md.Get("authorization") - if len(vals) == 0 { - return "" - } - v := vals[0] - if strings.HasPrefix(v, "Bearer ") { - return strings.TrimPrefix(v, "Bearer ") - } - return v -} diff --git a/internal/grpcserver/server.go b/internal/grpcserver/server.go index 5176ce7..981d012 100644 --- a/internal/grpcserver/server.go +++ b/internal/grpcserver/server.go @@ -2,16 +2,18 @@ package grpcserver import ( - "crypto/tls" + "context" "fmt" "log/slog" - "net" + "strings" "sync" "google.golang.org/grpc" - "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/mcdsl/grpcserver" + internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" "git.wntrmute.dev/kyle/metacrypt/internal/audit" "git.wntrmute.dev/kyle/metacrypt/internal/auth" @@ -21,7 +23,7 @@ import ( "git.wntrmute.dev/kyle/metacrypt/internal/seal" ) -// GRPCServer wraps the gRPC server and all service implementations. +// GRPCServer wraps the mcdsl gRPC server and all service implementations. type GRPCServer struct { cfg *config.Config sealMgr *seal.Manager @@ -30,7 +32,7 @@ type GRPCServer struct { engines *engine.Registry audit *audit.Logger logger *slog.Logger - srv *grpc.Server + srv *grpcserver.Server acmeHandlers map[string]*internacme.Handler mu sync.Mutex } @@ -57,61 +59,201 @@ func (s *GRPCServer) Start() error { return nil } - tlsCert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey) - if err != nil { - return fmt.Errorf("grpc: load TLS cert: %w", err) + opts := &grpcserver.Options{ + PreInterceptors: []grpc.UnaryServerInterceptor{sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods())}, + PostInterceptors: []grpc.UnaryServerInterceptor{auditInterceptor(s.audit)}, } - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, - MinVersion: tls.VersionTLS13, - } - creds := credentials.NewTLS(tlsCfg) - interceptor := chainInterceptors( - sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods()), - authInterceptor(s.auth, s.logger, authRequiredMethods()), - adminInterceptor(s.logger, adminRequiredMethods()), - auditInterceptor(s.audit), + srv, err := grpcserver.New( + s.cfg.Server.TLSCert, + s.cfg.Server.TLSKey, + s.auth, + methodMap(), + s.logger, + opts, ) - - s.srv = grpc.NewServer( - grpc.Creds(creds), - grpc.UnaryInterceptor(interceptor), - ) - - pb.RegisterSystemServiceServer(s.srv, &systemServer{s: s}) - pb.RegisterAuthServiceServer(s.srv, &authServer{s: s}) - pb.RegisterEngineServiceServer(s.srv, &engineServer{s: s}) - pb.RegisterPKIServiceServer(s.srv, &pkiServer{s: s}) - pb.RegisterCAServiceServer(s.srv, &caServer{s: s}) - pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s}) - pb.RegisterBarrierServiceServer(s.srv, &barrierServer{s: s}) - pb.RegisterUserServiceServer(s.srv, &userServer{s: s}) - pb.RegisterACMEServiceServer(s.srv, &acmeServer{s: s}) - pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s}) - pb.RegisterTransitServiceServer(s.srv, &transitServer{s: s}) - - lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr) if err != nil { - return fmt.Errorf("grpc: listen %s: %w", s.cfg.Server.GRPCAddr, err) + return fmt.Errorf("grpc: create server: %w", err) } + s.srv = srv - s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr) - if err := s.srv.Serve(lis); err != nil { - return fmt.Errorf("grpc: serve: %w", err) - } - return nil + pb.RegisterSystemServiceServer(srv.GRPCServer, &systemServer{s: s}) + pb.RegisterAuthServiceServer(srv.GRPCServer, &authServer{s: s}) + pb.RegisterEngineServiceServer(srv.GRPCServer, &engineServer{s: s}) + pb.RegisterPKIServiceServer(srv.GRPCServer, &pkiServer{s: s}) + pb.RegisterCAServiceServer(srv.GRPCServer, &caServer{s: s}) + pb.RegisterPolicyServiceServer(srv.GRPCServer, &policyServer{s: s}) + pb.RegisterBarrierServiceServer(srv.GRPCServer, &barrierServer{s: s}) + pb.RegisterUserServiceServer(srv.GRPCServer, &userServer{s: s}) + pb.RegisterACMEServiceServer(srv.GRPCServer, &acmeServer{s: s}) + pb.RegisterSSHCAServiceServer(srv.GRPCServer, &sshcaServer{s: s}) + pb.RegisterTransitServiceServer(srv.GRPCServer, &transitServer{s: s}) + + return srv.Serve(s.cfg.Server.GRPCAddr) } // Shutdown gracefully stops the gRPC server. func (s *GRPCServer) Shutdown() { if s.srv != nil { - s.srv.GracefulStop() + s.srv.Stop() + } +} + +// extractToken extracts the bearer token from gRPC metadata. Used by the +// auth service Logout handler which needs the raw token. +func extractToken(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + vals := md.Get("authorization") + if len(vals) == 0 { + return "" + } + v := vals[0] + if strings.HasPrefix(v, "Bearer ") { + return strings.TrimPrefix(v, "Bearer ") + } + return v +} + +// callerUsername returns the username from the token info in the context, +// or empty string if no token info is present. +func callerUsername(ctx context.Context) string { + ti := auth.TokenInfoFromContext(ctx) + if ti == nil { + return "" + } + return ti.Username +} + +// methodMap builds the mcdsl grpcserver.MethodMap for Metacrypt. +// +// Public methods require no authentication. Auth-required and admin-required +// methods are enforced by the mcdsl auth interceptor. +func methodMap() grpcserver.MethodMap { + return grpcserver.MethodMap{ + Public: publicMethods(), + + AuthRequired: authRequiredMethods(), + + AdminRequired: adminRequiredMethods(), + } +} + +// publicMethods returns methods that require no authentication. +// These include system lifecycle RPCs (status, init, unseal), the login +// endpoint, and read-only PKI/CA/SSH CA endpoints that serve public +// certificates and keys. +func publicMethods() map[string]bool { + return map[string]bool{ + // System lifecycle — always available. + "/metacrypt.v2.SystemService/Status": true, + "/metacrypt.v2.SystemService/Init": true, + "/metacrypt.v2.SystemService/Unseal": true, + // Auth — login requires no existing token. + "/metacrypt.v2.AuthService/Login": true, + // PKI read-only — public certificates. + "/metacrypt.v2.PKIService/GetRootCert": true, + "/metacrypt.v2.PKIService/GetChain": true, + "/metacrypt.v2.PKIService/GetIssuerCert": true, + // CA read-only — public certificates and chains. + "/metacrypt.v2.CAService/GetRoot": true, + "/metacrypt.v2.CAService/GetIssuer": true, + "/metacrypt.v2.CAService/ListIssuers": true, + "/metacrypt.v2.CAService/GetChain": true, + // SSH CA — public key and key revocation list. + "/metacrypt.v2.SSHCAService/GetCAPublicKey": true, + "/metacrypt.v2.SSHCAService/GetKRL": true, + } +} + +// authRequiredMethods returns methods that require a valid MCIAS token but +// not necessarily the admin role. +func authRequiredMethods() map[string]bool { + return map[string]bool{ + "/metacrypt.v2.AuthService/Logout": true, + "/metacrypt.v2.AuthService/TokenInfo": true, + "/metacrypt.v2.EngineService/ListMounts": true, + "/metacrypt.v2.CAService/IssueCert": true, + "/metacrypt.v2.CAService/GetCert": true, + "/metacrypt.v2.CAService/ListCerts": true, + "/metacrypt.v2.CAService/RenewCert": true, + "/metacrypt.v2.CAService/SignCSR": true, + "/metacrypt.v2.UserService/Register": true, + "/metacrypt.v2.UserService/GetPublicKey": true, + "/metacrypt.v2.UserService/ListUsers": true, + "/metacrypt.v2.UserService/Encrypt": true, + "/metacrypt.v2.UserService/Decrypt": true, + "/metacrypt.v2.UserService/ReEncrypt": true, + "/metacrypt.v2.UserService/RotateKey": true, + "/metacrypt.v2.ACMEService/CreateEAB": true, + // SSH CA — sign and read operations. + "/metacrypt.v2.SSHCAService/SignHost": true, + "/metacrypt.v2.SSHCAService/SignUser": true, + "/metacrypt.v2.SSHCAService/GetProfile": true, + "/metacrypt.v2.SSHCAService/ListProfiles": true, + "/metacrypt.v2.SSHCAService/GetCert": true, + "/metacrypt.v2.SSHCAService/ListCerts": true, + // Transit — data-plane operations. + "/metacrypt.v2.TransitService/GetKey": true, + "/metacrypt.v2.TransitService/ListKeys": true, + "/metacrypt.v2.TransitService/Encrypt": true, + "/metacrypt.v2.TransitService/Decrypt": true, + "/metacrypt.v2.TransitService/Rewrap": true, + "/metacrypt.v2.TransitService/BatchEncrypt": true, + "/metacrypt.v2.TransitService/BatchDecrypt": true, + "/metacrypt.v2.TransitService/BatchRewrap": true, + "/metacrypt.v2.TransitService/Sign": true, + "/metacrypt.v2.TransitService/Verify": true, + "/metacrypt.v2.TransitService/Hmac": true, + "/metacrypt.v2.TransitService/GetPublicKey": true, + } +} + +// adminRequiredMethods returns methods that require a valid MCIAS token +// with the admin role. +func adminRequiredMethods() map[string]bool { + return map[string]bool{ + "/metacrypt.v2.SystemService/Seal": true, + "/metacrypt.v2.EngineService/Mount": true, + "/metacrypt.v2.EngineService/Unmount": true, + "/metacrypt.v2.CAService/ImportRoot": true, + "/metacrypt.v2.CAService/CreateIssuer": true, + "/metacrypt.v2.CAService/DeleteIssuer": true, + "/metacrypt.v2.CAService/RevokeCert": true, + "/metacrypt.v2.CAService/DeleteCert": true, + "/metacrypt.v2.PolicyService/CreatePolicy": true, + "/metacrypt.v2.PolicyService/ListPolicies": true, + "/metacrypt.v2.PolicyService/GetPolicy": true, + "/metacrypt.v2.PolicyService/DeletePolicy": true, + // User. + "/metacrypt.v2.UserService/Provision": true, + "/metacrypt.v2.UserService/DeleteUser": true, + "/metacrypt.v2.ACMEService/SetConfig": true, + "/metacrypt.v2.ACMEService/ListAccounts": true, + "/metacrypt.v2.ACMEService/ListOrders": true, + "/metacrypt.v2.BarrierService/ListKeys": true, + "/metacrypt.v2.BarrierService/RotateMEK": true, + "/metacrypt.v2.BarrierService/RotateKey": true, + "/metacrypt.v2.BarrierService/Migrate": true, + // SSH CA. + "/metacrypt.v2.SSHCAService/CreateProfile": true, + "/metacrypt.v2.SSHCAService/UpdateProfile": true, + "/metacrypt.v2.SSHCAService/DeleteProfile": true, + "/metacrypt.v2.SSHCAService/RevokeCert": true, + "/metacrypt.v2.SSHCAService/DeleteCert": true, + // Transit. + "/metacrypt.v2.TransitService/CreateKey": true, + "/metacrypt.v2.TransitService/DeleteKey": true, + "/metacrypt.v2.TransitService/RotateKey": true, + "/metacrypt.v2.TransitService/UpdateKeyConfig": true, + "/metacrypt.v2.TransitService/TrimKey": true, } } // sealRequiredMethods returns the set of RPC full names that require the vault -// to be unsealed. +// to be unsealed. This is used by the sealInterceptor PreInterceptor. func sealRequiredMethods() map[string]bool { return map[string]bool{ "/metacrypt.v2.AuthService/Login": true, @@ -153,11 +295,11 @@ func sealRequiredMethods() map[string]bool { "/metacrypt.v2.ACMEService/CreateEAB": true, "/metacrypt.v2.ACMEService/SetConfig": true, "/metacrypt.v2.ACMEService/ListAccounts": true, - "/metacrypt.v2.ACMEService/ListOrders": true, - "/metacrypt.v2.BarrierService/ListKeys": true, - "/metacrypt.v2.BarrierService/RotateMEK": true, - "/metacrypt.v2.BarrierService/RotateKey": true, - "/metacrypt.v2.BarrierService/Migrate": true, + "/metacrypt.v2.ACMEService/ListOrders": true, + "/metacrypt.v2.BarrierService/ListKeys": true, + "/metacrypt.v2.BarrierService/RotateMEK": true, + "/metacrypt.v2.BarrierService/RotateKey": true, + "/metacrypt.v2.BarrierService/Migrate": true, // SSH CA. "/metacrypt.v2.SSHCAService/GetCAPublicKey": true, "/metacrypt.v2.SSHCAService/SignHost": true, @@ -190,118 +332,7 @@ func sealRequiredMethods() map[string]bool { "/metacrypt.v2.TransitService/Verify": true, "/metacrypt.v2.TransitService/Hmac": true, "/metacrypt.v2.TransitService/GetPublicKey": true, - } -} - -// authRequiredMethods returns the set of RPC full names that require a valid token. -func authRequiredMethods() map[string]bool { - return map[string]bool{ - "/metacrypt.v2.AuthService/Logout": true, - "/metacrypt.v2.AuthService/TokenInfo": true, - "/metacrypt.v2.EngineService/Mount": true, - "/metacrypt.v2.EngineService/Unmount": true, - "/metacrypt.v2.EngineService/ListMounts": true, - "/metacrypt.v2.CAService/ImportRoot": true, - "/metacrypt.v2.CAService/CreateIssuer": true, - "/metacrypt.v2.CAService/DeleteIssuer": true, - "/metacrypt.v2.CAService/ListIssuers": true, - "/metacrypt.v2.CAService/IssueCert": true, - "/metacrypt.v2.CAService/GetCert": true, - "/metacrypt.v2.CAService/ListCerts": true, - "/metacrypt.v2.CAService/RenewCert": true, - "/metacrypt.v2.CAService/SignCSR": true, - "/metacrypt.v2.CAService/RevokeCert": true, - "/metacrypt.v2.CAService/DeleteCert": true, - "/metacrypt.v2.PolicyService/CreatePolicy": true, - "/metacrypt.v2.PolicyService/ListPolicies": true, - "/metacrypt.v2.PolicyService/GetPolicy": true, - "/metacrypt.v2.PolicyService/DeletePolicy": true, - "/metacrypt.v2.UserService/Register": true, - "/metacrypt.v2.UserService/Provision": true, - "/metacrypt.v2.UserService/GetPublicKey": true, - "/metacrypt.v2.UserService/ListUsers": true, - "/metacrypt.v2.UserService/Encrypt": true, - "/metacrypt.v2.UserService/Decrypt": true, - "/metacrypt.v2.UserService/ReEncrypt": true, - "/metacrypt.v2.UserService/RotateKey": true, - "/metacrypt.v2.UserService/DeleteUser": true, - "/metacrypt.v2.ACMEService/CreateEAB": true, - "/metacrypt.v2.ACMEService/SetConfig": true, - "/metacrypt.v2.ACMEService/ListAccounts": true, - "/metacrypt.v2.ACMEService/ListOrders": true, - "/metacrypt.v2.BarrierService/ListKeys": true, - "/metacrypt.v2.BarrierService/RotateMEK": true, - "/metacrypt.v2.BarrierService/RotateKey": true, - "/metacrypt.v2.BarrierService/Migrate": true, - // SSH CA. - "/metacrypt.v2.SSHCAService/SignHost": true, - "/metacrypt.v2.SSHCAService/SignUser": true, - "/metacrypt.v2.SSHCAService/CreateProfile": true, - "/metacrypt.v2.SSHCAService/UpdateProfile": true, - "/metacrypt.v2.SSHCAService/GetProfile": true, - "/metacrypt.v2.SSHCAService/ListProfiles": true, - "/metacrypt.v2.SSHCAService/DeleteProfile": true, - "/metacrypt.v2.SSHCAService/GetCert": true, - "/metacrypt.v2.SSHCAService/ListCerts": true, - "/metacrypt.v2.SSHCAService/RevokeCert": true, - "/metacrypt.v2.SSHCAService/DeleteCert": true, - // Transit. - "/metacrypt.v2.TransitService/CreateKey": true, - "/metacrypt.v2.TransitService/DeleteKey": true, - "/metacrypt.v2.TransitService/GetKey": true, - "/metacrypt.v2.TransitService/ListKeys": true, - "/metacrypt.v2.TransitService/RotateKey": true, - "/metacrypt.v2.TransitService/UpdateKeyConfig": true, - "/metacrypt.v2.TransitService/TrimKey": true, - "/metacrypt.v2.TransitService/Encrypt": true, - "/metacrypt.v2.TransitService/Decrypt": true, - "/metacrypt.v2.TransitService/Rewrap": true, - "/metacrypt.v2.TransitService/BatchEncrypt": true, - "/metacrypt.v2.TransitService/BatchDecrypt": true, - "/metacrypt.v2.TransitService/BatchRewrap": true, - "/metacrypt.v2.TransitService/Sign": true, - "/metacrypt.v2.TransitService/Verify": true, - "/metacrypt.v2.TransitService/Hmac": true, - "/metacrypt.v2.TransitService/GetPublicKey": true, - } -} - -// adminRequiredMethods returns the set of RPC full names that require admin. -func adminRequiredMethods() map[string]bool { - return map[string]bool{ - "/metacrypt.v2.SystemService/Seal": true, - "/metacrypt.v2.EngineService/Mount": true, - "/metacrypt.v2.EngineService/Unmount": true, - "/metacrypt.v2.CAService/ImportRoot": true, - "/metacrypt.v2.CAService/CreateIssuer": true, - "/metacrypt.v2.CAService/DeleteIssuer": true, - "/metacrypt.v2.CAService/RevokeCert": true, - "/metacrypt.v2.CAService/DeleteCert": true, - "/metacrypt.v2.PolicyService/CreatePolicy": true, - "/metacrypt.v2.PolicyService/ListPolicies": true, - "/metacrypt.v2.PolicyService/GetPolicy": true, - "/metacrypt.v2.PolicyService/DeletePolicy": true, - // User. - "/metacrypt.v2.UserService/Provision": true, - "/metacrypt.v2.UserService/DeleteUser": true, - "/metacrypt.v2.ACMEService/SetConfig": true, - "/metacrypt.v2.ACMEService/ListAccounts": true, - "/metacrypt.v2.ACMEService/ListOrders": true, - "/metacrypt.v2.BarrierService/ListKeys": true, - "/metacrypt.v2.BarrierService/RotateMEK": true, - "/metacrypt.v2.BarrierService/RotateKey": true, - "/metacrypt.v2.BarrierService/Migrate": true, - // SSH CA. - "/metacrypt.v2.SSHCAService/CreateProfile": true, - "/metacrypt.v2.SSHCAService/UpdateProfile": true, - "/metacrypt.v2.SSHCAService/DeleteProfile": true, - "/metacrypt.v2.SSHCAService/RevokeCert": true, - "/metacrypt.v2.SSHCAService/DeleteCert": true, - // Transit. - "/metacrypt.v2.TransitService/CreateKey": true, - "/metacrypt.v2.TransitService/DeleteKey": true, - "/metacrypt.v2.TransitService/RotateKey": true, - "/metacrypt.v2.TransitService/UpdateKeyConfig": true, - "/metacrypt.v2.TransitService/TrimKey": true, + // Seal itself requires unsealed state. + "/metacrypt.v2.SystemService/Seal": true, } } diff --git a/internal/grpcserver/sshca.go b/internal/grpcserver/sshca.go index 3cf250e..0b9a6f5 100644 --- a/internal/grpcserver/sshca.go +++ b/internal/grpcserver/sshca.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" @@ -50,7 +51,7 @@ func (ss *sshcaServer) sshcaHandleRequest(ctx context.Context, mount, operation } func (ss *sshcaServer) callerInfo(ctx context.Context) *engine.CallerInfo { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) if ti == nil { return nil } diff --git a/internal/grpcserver/transit.go b/internal/grpcserver/transit.go index f5fc2f3..4d57cd4 100644 --- a/internal/grpcserver/transit.go +++ b/internal/grpcserver/transit.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc/status" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/transit" "git.wntrmute.dev/kyle/metacrypt/internal/policy" @@ -58,7 +59,7 @@ func (ts *transitServer) transitHandleRequest(ctx context.Context, mount, operat } func (ts *transitServer) callerInfo(ctx context.Context) *engine.CallerInfo { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) if ti == nil { return nil } diff --git a/internal/grpcserver/user.go b/internal/grpcserver/user.go index ca247d6..066e414 100644 --- a/internal/grpcserver/user.go +++ b/internal/grpcserver/user.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc/status" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" + "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/engine" "git.wntrmute.dev/kyle/metacrypt/internal/engine/user" "git.wntrmute.dev/kyle/metacrypt/internal/policy" @@ -20,7 +21,7 @@ type userServer struct { } func (us *userServer) callerInfo(ctx context.Context) *engine.CallerInfo { - ti := tokenInfoFromContext(ctx) + ti := auth.TokenInfoFromContext(ctx) if ti == nil { return nil }