Files
mcr/internal/grpcserver/interceptors.go
Kyle Isom 185b68ff6d Phase 10: gRPC admin API with interceptor chain
Proto definitions for 4 services (RegistryService, PolicyService,
AuditService, AdminService) with hand-written Go stubs using JSON
codec until protobuf tooling is available.

Interceptor chain: logging (method, peer IP, duration, never logs
auth metadata) → auth (bearer token via MCIAS, Health bypasses) →
admin (role check for GC, policy, delete, audit RPCs).

All RPCs share business logic with REST handlers via internal/db
and internal/gc packages. TLS 1.3 minimum on gRPC listener.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:46:21 -07:00

166 lines
4.6 KiB
Go

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
}