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>
This commit is contained in:
165
internal/grpcserver/interceptors.go
Normal file
165
internal/grpcserver/interceptors.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user