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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 14:42:41 -07:00
parent d308db8598
commit 310ed83f28
12 changed files with 264 additions and 378 deletions

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.7
require ( require (
git.wntrmute.dev/kyle/goutils v1.21.0 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/go-chi/chi/v5 v5.2.5
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0

2
go.sum
View File

@@ -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/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 h1:YB7dx4gdNYKKcVySpL6UkwHqdCJ9Nl1yS0+eHk0hNtk=
git.wntrmute.dev/kyle/mcdsl v1.0.0/go.mod h1:wo0tGfUAxci3XnOe4/rFmR0RjUElKdYUazc+Np986sg= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

View File

@@ -10,6 +10,7 @@ import (
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/engine" "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) { 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) h, err := as.getOrCreateHandler(req.Mount)
if err != nil { if err != nil {
return nil, status.Error(codes.NotFound, "mount not found") return nil, status.Error(codes.NotFound, "mount not found")

View File

@@ -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) { func (as *authServer) TokenInfo(ctx context.Context, _ *pb.TokenInfoRequest) (*pb.TokenInfoResponse, error) {
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
if ti == nil { if ti == nil {
// Shouldn't happen — authInterceptor runs first — but guard anyway. // Shouldn't happen — authInterceptor runs first — but guard anyway.
token := extractToken(ctx) token := extractToken(ctx)

View File

@@ -11,6 +11,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" 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"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/policy"
@@ -51,16 +52,9 @@ func (cs *caServer) caHandleRequest(ctx context.Context, mount, operation string
return resp, nil 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 { func (cs *caServer) callerInfo(ctx context.Context) *engine.CallerInfo {
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
if ti == nil { if ti == nil {
return nil return nil
} }

View File

@@ -8,6 +8,7 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" 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"
) )
@@ -48,7 +49,7 @@ func (es *engineServer) Mount(ctx context.Context, req *pb.MountRequest) (*pb.Mo
return nil, status.Error(codes.Internal, err.Error()) return nil, status.Error(codes.Internal, err.Error())
} }
} }
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
username := "" username := ""
if ti != nil { if ti != nil {
username = ti.Username 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()) return nil, status.Error(codes.Internal, err.Error())
} }
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
username := "" username := ""
if ti != nil { if ti != nil {
username = ti.Username username = ti.Username

View File

@@ -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) { func TestExtractToken(t *testing.T) {
tests := []struct { tests := []struct {
name string 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 ---- // ---- systemServer tests ----
func TestSystemStatus(t *testing.T) { func TestSystemStatus(t *testing.T) {
@@ -668,7 +575,7 @@ func TestAuthTokenInfo_FromContext(t *testing.T) {
as := &authServer{s: srv} as := &authServer{s: srv}
ti := &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false} 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{}) resp, err := as.TokenInfo(ctx, &pb.TokenInfoRequest{})
if err != nil { if err != nil {
@@ -718,3 +625,33 @@ func TestPbToRuleRoundtrip(t *testing.T) {
t.Errorf("roundtrip Effect: got %q, want %q", back.Effect, original.Effect) 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)
}
}
}

View File

@@ -4,11 +4,9 @@ import (
"context" "context"
"log/slog" "log/slog"
"path" "path"
"strings"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/metacrypt/internal/audit" "git.wntrmute.dev/kyle/metacrypt/internal/audit"
@@ -16,61 +14,9 @@ import (
"git.wntrmute.dev/kyle/metacrypt/internal/seal" "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 // 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 { 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) { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !methods[info.FullMethod] { 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. // auditInterceptor logs an audit event after each RPC completes. It is
func chainInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor { // intended to run as a PostInterceptor in the mcdsl grpcserver chain,
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { // after auth so that caller info is available in the context via
chain := handler // auth.TokenInfoFromContext.
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.
func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor { func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req) resp, err := handler(ctx, req)
caller := "anonymous" caller := "anonymous"
var roles []string var roles []string
if ti := tokenInfoFromContext(ctx); ti != nil { if ti := auth.TokenInfoFromContext(ctx); ti != nil {
caller = ti.Username caller = ti.Username
roles = ti.Roles roles = ti.Roles
} }
@@ -138,19 +71,3 @@ func auditInterceptor(auditLog *audit.Logger) grpc.UnaryServerInterceptor {
return resp, err 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
}

View File

@@ -2,16 +2,18 @@
package grpcserver package grpcserver
import ( import (
"crypto/tls" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "strings"
"sync" "sync"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2"
"git.wntrmute.dev/kyle/mcdsl/grpcserver"
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
"git.wntrmute.dev/kyle/metacrypt/internal/audit" "git.wntrmute.dev/kyle/metacrypt/internal/audit"
"git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/auth"
@@ -21,7 +23,7 @@ import (
"git.wntrmute.dev/kyle/metacrypt/internal/seal" "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 { type GRPCServer struct {
cfg *config.Config cfg *config.Config
sealMgr *seal.Manager sealMgr *seal.Manager
@@ -30,7 +32,7 @@ type GRPCServer struct {
engines *engine.Registry engines *engine.Registry
audit *audit.Logger audit *audit.Logger
logger *slog.Logger logger *slog.Logger
srv *grpc.Server srv *grpcserver.Server
acmeHandlers map[string]*internacme.Handler acmeHandlers map[string]*internacme.Handler
mu sync.Mutex mu sync.Mutex
} }
@@ -57,61 +59,201 @@ func (s *GRPCServer) Start() error {
return nil return nil
} }
tlsCert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey) opts := &grpcserver.Options{
if err != nil { PreInterceptors: []grpc.UnaryServerInterceptor{sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods())},
return fmt.Errorf("grpc: load TLS cert: %w", err) PostInterceptors: []grpc.UnaryServerInterceptor{auditInterceptor(s.audit)},
} }
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS13,
}
creds := credentials.NewTLS(tlsCfg)
interceptor := chainInterceptors( srv, err := grpcserver.New(
sealInterceptor(s.sealMgr, s.logger, sealRequiredMethods()), s.cfg.Server.TLSCert,
authInterceptor(s.auth, s.logger, authRequiredMethods()), s.cfg.Server.TLSKey,
adminInterceptor(s.logger, adminRequiredMethods()), s.auth,
auditInterceptor(s.audit), 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 { 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) pb.RegisterSystemServiceServer(srv.GRPCServer, &systemServer{s: s})
if err := s.srv.Serve(lis); err != nil { pb.RegisterAuthServiceServer(srv.GRPCServer, &authServer{s: s})
return fmt.Errorf("grpc: serve: %w", err) pb.RegisterEngineServiceServer(srv.GRPCServer, &engineServer{s: s})
} pb.RegisterPKIServiceServer(srv.GRPCServer, &pkiServer{s: s})
return nil 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. // Shutdown gracefully stops the gRPC server.
func (s *GRPCServer) Shutdown() { func (s *GRPCServer) Shutdown() {
if s.srv != nil { 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 // 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 { func sealRequiredMethods() map[string]bool {
return map[string]bool{ return map[string]bool{
"/metacrypt.v2.AuthService/Login": true, "/metacrypt.v2.AuthService/Login": true,
@@ -190,118 +332,7 @@ func sealRequiredMethods() map[string]bool {
"/metacrypt.v2.TransitService/Verify": true, "/metacrypt.v2.TransitService/Verify": true,
"/metacrypt.v2.TransitService/Hmac": true, "/metacrypt.v2.TransitService/Hmac": true,
"/metacrypt.v2.TransitService/GetPublicKey": true, "/metacrypt.v2.TransitService/GetPublicKey": true,
} // Seal itself requires unsealed state.
}
// 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.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,
} }
} }

View File

@@ -11,6 +11,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" 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"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca" "git.wntrmute.dev/kyle/metacrypt/internal/engine/sshca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy" "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 { func (ss *sshcaServer) callerInfo(ctx context.Context) *engine.CallerInfo {
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
if ti == nil { if ti == nil {
return nil return nil
} }

View File

@@ -9,6 +9,7 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" 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"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/transit" "git.wntrmute.dev/kyle/metacrypt/internal/engine/transit"
"git.wntrmute.dev/kyle/metacrypt/internal/policy" "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 { func (ts *transitServer) callerInfo(ctx context.Context) *engine.CallerInfo {
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
if ti == nil { if ti == nil {
return nil return nil
} }

View File

@@ -9,6 +9,7 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v2" 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"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/user" "git.wntrmute.dev/kyle/metacrypt/internal/engine/user"
"git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/policy"
@@ -20,7 +21,7 @@ type userServer struct {
} }
func (us *userServer) callerInfo(ctx context.Context) *engine.CallerInfo { func (us *userServer) callerInfo(ctx context.Context) *engine.CallerInfo {
ti := tokenInfoFromContext(ctx) ti := auth.TokenInfoFromContext(ctx)
if ti == nil { if ti == nil {
return nil return nil
} }