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:
2026-03-26 14:46:03 -07:00
parent ef39152f4e
commit 758aa91bfc
11 changed files with 495 additions and 310 deletions

View File

@@ -2,7 +2,11 @@ package grpcserver
import (
"context"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
@@ -12,19 +16,69 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/db"
)
// fakeValidator is a test double for server.TokenValidator.
type fakeValidator struct {
claims *auth.Claims
err error
// mockMCIAS starts a fake MCIAS HTTP server for token validation.
// Recognized tokens:
// - "admin-token" → valid, username=admin-uuid, roles=[admin]
// - "user-token" → valid, username=user-uuid, account_type=human, roles=[user]
// - "alice-token" → valid, username=alice, account_type=human, roles=[user]
// - anything else → invalid
func mockMCIAS(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch req.Token {
case "admin-token":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "admin-uuid",
"account_type": "human",
"roles": []string{"admin"},
})
case "user-token":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "user-uuid",
"account_type": "human",
"roles": []string{"user"},
})
case "alice-token":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"valid": true,
"username": "alice",
"account_type": "human",
"roles": []string{"user"},
})
default:
_ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false})
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func (f *fakeValidator) ValidateToken(_ string) (*auth.Claims, error) {
return f.claims, f.err
// testAuthenticator creates an mcdsl/auth.Authenticator that talks to the given mock MCIAS.
func testAuthenticator(t *testing.T, serverURL string) *mcdslauth.Authenticator {
t.Helper()
a, err := mcdslauth.New(mcdslauth.Config{ServerURL: serverURL}, slog.Default())
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return a
}
// openTestDB creates a temporary test database with migrations applied.
@@ -43,11 +97,10 @@ func openTestDB(t *testing.T) *db.DB {
}
// startTestServer creates a gRPC server and client for testing.
// Returns the client connection and a cleanup function.
func startTestServer(t *testing.T, deps Deps) *grpc.ClientConn {
t.Helper()
srv, err := New("", "", deps)
srv, err := New("", "", deps, slog.Default())
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -82,12 +135,13 @@ func withAuth(ctx context.Context, token string) context.Context {
}
func TestHealthBypassesAuth(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
client := pb.NewAdminServiceClient(cc)
@@ -101,12 +155,13 @@ func TestHealthBypassesAuth(t *testing.T) {
}
func TestAuthInterceptorNoToken(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
client := pb.NewRegistryServiceClient(cc)
@@ -125,12 +180,13 @@ func TestAuthInterceptorNoToken(t *testing.T) {
}
func TestAuthInterceptorInvalidToken(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "bad-token")
@@ -150,17 +206,16 @@ func TestAuthInterceptorInvalidToken(t *testing.T) {
}
func TestAuthInterceptorValidToken(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "alice", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRegistryServiceClient(cc)
resp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{})
if err != nil {
@@ -172,17 +227,16 @@ func TestAuthInterceptorValidToken(t *testing.T) {
}
func TestAdminInterceptorDenied(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "user-token")
// Policy RPCs require admin.
policyClient := pb.NewPolicyServiceClient(cc)
@@ -201,17 +255,16 @@ func TestAdminInterceptorDenied(t *testing.T) {
}
func TestAdminInterceptorAllowed(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "admin-token")
// Admin user should be able to list policy rules.
policyClient := pb.NewPolicyServiceClient(cc)
@@ -224,11 +277,11 @@ func TestAdminInterceptorAllowed(t *testing.T) {
}
}
func TestAdminRequiredMethodsCompleteness(t *testing.T) {
func TestMethodMapCompleteness(t *testing.T) {
mm := methodMap()
// Verify that admin-required methods match our security spec.
// This test catches the security defect of adding an RPC without
// adding it to the adminRequiredMethods map.
expected := []string{
expectedAdmin := []string{
"/mcr.v1.RegistryService/DeleteRepository",
"/mcr.v1.RegistryService/GarbageCollect",
"/mcr.v1.RegistryService/GetGCStatus",
@@ -240,46 +293,59 @@ func TestAdminRequiredMethodsCompleteness(t *testing.T) {
"/mcr.v1.AuditService/ListAuditEvents",
}
for _, method := range expected {
if !adminRequiredMethods[method] {
t.Errorf("method %s should require admin but is not in adminRequiredMethods", method)
for _, method := range expectedAdmin {
if !mm.AdminRequired[method] {
t.Errorf("method %s should require admin but is not in AdminRequired", method)
}
}
if len(adminRequiredMethods) != len(expected) {
t.Errorf("adminRequiredMethods has %d entries, expected %d", len(adminRequiredMethods), len(expected))
if len(mm.AdminRequired) != len(expectedAdmin) {
t.Errorf("AdminRequired has %d entries, expected %d", len(mm.AdminRequired), len(expectedAdmin))
}
}
func TestAuthBypassMethodsCompleteness(t *testing.T) {
// Health is the only method that bypasses auth.
expected := []string{
// Health is the only public method.
expectedPublic := []string{
"/mcr.v1.AdminService/Health",
}
for _, method := range expected {
if !authBypassMethods[method] {
t.Errorf("method %s should bypass auth but is not in authBypassMethods", method)
for _, method := range expectedPublic {
if !mm.Public[method] {
t.Errorf("method %s should be public but is not in Public", method)
}
}
if len(authBypassMethods) != len(expected) {
t.Errorf("authBypassMethods has %d entries, expected %d", len(authBypassMethods), len(expected))
if len(mm.Public) != len(expectedPublic) {
t.Errorf("Public has %d entries, expected %d", len(mm.Public), len(expectedPublic))
}
// Auth-required methods.
expectedAuth := []string{
"/mcr.v1.RegistryService/ListRepositories",
"/mcr.v1.RegistryService/GetRepository",
}
for _, method := range expectedAuth {
if !mm.AuthRequired[method] {
t.Errorf("method %s should require auth but is not in AuthRequired", method)
}
}
if len(mm.AuthRequired) != len(expectedAuth) {
t.Errorf("AuthRequired has %d entries, expected %d", len(mm.AuthRequired), len(expectedAuth))
}
}
func TestDeleteRepoRequiresAdmin(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRegistryServiceClient(cc)
_, err := client.DeleteRepository(ctx, &pb.DeleteRepositoryRequest{Name: "test"})
if err == nil {
@@ -296,17 +362,16 @@ func TestDeleteRepoRequiresAdmin(t *testing.T) {
}
func TestGCRequiresAdmin(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "user-token")
client := pb.NewRegistryServiceClient(cc)
_, err := client.GarbageCollect(ctx, &pb.GarbageCollectRequest{})
if err == nil {
@@ -323,17 +388,16 @@ func TestGCRequiresAdmin(t *testing.T) {
}
func TestAuditRequiresAdmin(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
validator := &fakeValidator{
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
ctx := withAuth(context.Background(), "valid-token")
ctx := withAuth(context.Background(), "user-token")
client := pb.NewAuditServiceClient(cc)
_, err := client.ListAuditEvents(ctx, &pb.ListAuditEventsRequest{})
if err == nil {