Migrate gRPC server to mcdsl grpcserver package
Replace MCR's custom auth, admin, and logging interceptors with the shared mcdsl grpcserver package. This eliminates ~110 lines of interceptor code and uses the same method-map auth pattern used by metacrypt. Key changes: - server.go: delegate to mcdslgrpc.New() for TLS, logging, and auth - interceptors.go: replaced with MethodMap definition (public, auth-required, admin-required) - Handler files: switch from auth.ClaimsFromContext to mcdslauth.TokenInfoFromContext - auth/client.go: add Authenticator() accessor for the underlying mcdsl authenticator - Tests: use mock MCIAS HTTP server instead of fakeValidator interface - Vendor: add mcdsl/grpcserver to vendor directory ListRepositories and GetRepository are now explicitly auth-required (not admin-required), matching the REST API. Previously they were implicitly auth-required by not being in the bypass or admin maps. Security: method map uses default-deny -- unmapped RPCs are rejected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,165 +1,55 @@
|
||||
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"
|
||||
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
|
||||
)
|
||||
|
||||
// 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,
|
||||
// methodMap builds the mcdsl grpcserver.MethodMap for MCR.
|
||||
//
|
||||
// Adding a new RPC without adding it to the correct map is a security
|
||||
// defect -- the mcdsl auth interceptor denies unmapped methods by default.
|
||||
func methodMap() mcdslgrpc.MethodMap {
|
||||
return mcdslgrpc.MethodMap{
|
||||
Public: publicMethods(),
|
||||
AuthRequired: authRequiredMethods(),
|
||||
AdminRequired: adminRequiredMethods(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
// publicMethods returns methods that require no authentication.
|
||||
// Health is the only public RPC.
|
||||
func publicMethods() map[string]bool {
|
||||
return map[string]bool{
|
||||
"/mcr.v1.AdminService/Health": true,
|
||||
}
|
||||
}
|
||||
|
||||
// authInterceptor validates bearer tokens from the authorization metadata.
|
||||
type authInterceptor struct {
|
||||
validator server.TokenValidator
|
||||
// authRequiredMethods returns methods that require a valid MCIAS token
|
||||
// but not the admin role.
|
||||
func authRequiredMethods() map[string]bool {
|
||||
return map[string]bool{
|
||||
"/mcr.v1.RegistryService/ListRepositories": true,
|
||||
"/mcr.v1.RegistryService/GetRepository": true,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// adminRequiredMethods returns methods that require a valid MCIAS token
|
||||
// with the admin role.
|
||||
func adminRequiredMethods() map[string]bool {
|
||||
return 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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user