package grpcserver import ( "context" "log" "strings" "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" "git.wntrmute.dev/kyle/mcr/internal/auth" "git.wntrmute.dev/kyle/mcr/internal/server" ) // authBypassMethods contains the full gRPC method names that bypass // authentication. Health is the only method that does not require auth. var authBypassMethods = map[string]bool{ "/mcr.v1.AdminService/Health": true, } // adminRequiredMethods contains the full gRPC method names that require // the admin role. Adding an RPC without adding it to the correct map is // a security defect per ARCHITECTURE.md. var adminRequiredMethods = map[string]bool{ // Registry admin operations. "/mcr.v1.RegistryService/DeleteRepository": true, "/mcr.v1.RegistryService/GarbageCollect": true, "/mcr.v1.RegistryService/GetGCStatus": true, // Policy management — all RPCs require admin. "/mcr.v1.PolicyService/ListPolicyRules": true, "/mcr.v1.PolicyService/CreatePolicyRule": true, "/mcr.v1.PolicyService/GetPolicyRule": true, "/mcr.v1.PolicyService/UpdatePolicyRule": true, "/mcr.v1.PolicyService/DeletePolicyRule": true, // Audit — requires admin. "/mcr.v1.AuditService/ListAuditEvents": true, } // authInterceptor validates bearer tokens from the authorization metadata. type authInterceptor struct { validator server.TokenValidator } func newAuthInterceptor(v server.TokenValidator) *authInterceptor { return &authInterceptor{validator: v} } // unary is the unary server interceptor for auth. func (a *authInterceptor) unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { // Health bypasses auth. if authBypassMethods[info.FullMethod] { return handler(ctx, req) } // Extract bearer token from authorization metadata. token, err := extractToken(ctx) if err != nil { return nil, status.Errorf(codes.Unauthenticated, "authentication required") } // Validate the token via MCIAS. claims, err := a.validator.ValidateToken(token) if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid token") } // Inject claims into the context. ctx = auth.ContextWithClaims(ctx, claims) return handler(ctx, req) } // extractToken extracts a bearer token from the "authorization" gRPC metadata. func extractToken(ctx context.Context) (string, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return "", status.Errorf(codes.Unauthenticated, "missing metadata") } vals := md.Get("authorization") if len(vals) == 0 { return "", status.Errorf(codes.Unauthenticated, "missing authorization metadata") } val := vals[0] const prefix = "Bearer " if !strings.HasPrefix(val, prefix) { return "", status.Errorf(codes.Unauthenticated, "invalid authorization format") } token := strings.TrimSpace(val[len(prefix):]) if token == "" { return "", status.Errorf(codes.Unauthenticated, "empty bearer token") } return token, nil } // adminInterceptor checks that the caller has the admin role for // methods in adminRequiredMethods. type adminInterceptor struct{} func newAdminInterceptor() *adminInterceptor { return &adminInterceptor{} } // unary is the unary server interceptor for admin role checks. func (a *adminInterceptor) unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { if !adminRequiredMethods[info.FullMethod] { return handler(ctx, req) } claims := auth.ClaimsFromContext(ctx) if claims == nil { return nil, status.Errorf(codes.Unauthenticated, "authentication required") } if !hasRole(claims.Roles, "admin") { return nil, status.Errorf(codes.PermissionDenied, "admin role required") } return handler(ctx, req) } // loggingInterceptor logs the method, peer IP, status code, and duration. // It never logs the authorization metadata value. func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { start := time.Now() peerAddr := "" if p, ok := peer.FromContext(ctx); ok { peerAddr = p.Addr.String() } resp, err := handler(ctx, req) duration := time.Since(start) code := codes.OK if err != nil { if st, ok := status.FromError(err); ok { code = st.Code() } else { code = codes.Unknown } } log.Printf("grpc %s peer=%s code=%s duration=%s", info.FullMethod, peerAddr, code, duration) return resp, err } // hasRole checks if any of the roles match the target role. func hasRole(roles []string, target string) bool { for _, r := range roles { if r == target { return true } } return false }