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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user