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

@@ -15,6 +15,13 @@ type Client struct {
auth *mcdslauth.Authenticator
}
// Authenticator returns the underlying mcdsl/auth.Authenticator. This is
// used by the gRPC server which delegates auth to the mcdsl grpcserver
// package.
func (c *Client) Authenticator() *mcdslauth.Authenticator {
return c.auth
}
// NewClient creates an auth Client that talks to the MCIAS server at
// serverURL. If caCert is non-empty, it is used as a custom CA cert.
// TLS 1.3 is required for all HTTPS connections.

View File

@@ -5,7 +5,6 @@ import (
"testing"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
)
func TestHealthReturnsOk(t *testing.T) {
@@ -23,13 +22,13 @@ func TestHealthReturnsOk(t *testing.T) {
}
func TestHealthWithoutAuth(t *testing.T) {
mcias := mockMCIAS(t)
auth := testAuthenticator(t, mcias.URL)
database := openTestDB(t)
// Use a validator that always rejects.
validator := &fakeValidator{err: auth.ErrUnauthorized}
cc := startTestServer(t, Deps{
DB: database,
Validator: validator,
DB: database,
Authenticator: auth,
})
client := pb.NewAdminServiceClient(cc)

View File

@@ -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,
}
}

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 {

View File

@@ -9,8 +9,9 @@ import (
"google.golang.org/grpc/codes"
"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"
"git.wntrmute.dev/kyle/mcr/internal/policy"
)
@@ -74,10 +75,10 @@ func (s *policyService) CreatePolicyRule(ctx context.Context, req *pb.CreatePoli
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
}
claims := auth.ClaimsFromContext(ctx)
info := mcdslauth.TokenInfoFromContext(ctx)
createdBy := ""
if claims != nil {
createdBy = claims.Subject
if info != nil {
createdBy = info.Username
}
row := db.PolicyRuleRow{
@@ -207,10 +208,10 @@ func (s *policyService) UpdatePolicyRule(ctx context.Context, req *pb.UpdatePoli
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
info := mcdslauth.TokenInfoFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
if info != nil {
actorID = info.Username
}
s.auditFn("policy_rule_updated", actorID, "", "", "", map[string]string{
"rule_id": strconv.FormatInt(req.Id, 10),
@@ -243,10 +244,10 @@ func (s *policyService) DeletePolicyRule(ctx context.Context, req *pb.DeletePoli
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
info := mcdslauth.TokenInfoFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
if info != nil {
actorID = info.Username
}
s.auditFn("policy_rule_deleted", actorID, "", "", "", map[string]string{
"rule_id": strconv.FormatInt(req.Id, 10),

View File

@@ -11,8 +11,9 @@ import (
"github.com/google/uuid"
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"
"git.wntrmute.dev/kyle/mcr/internal/gc"
)
@@ -106,10 +107,10 @@ func (s *registryService) DeleteRepository(ctx context.Context, req *pb.DeleteRe
}
if s.auditFn != nil {
claims := auth.ClaimsFromContext(ctx)
info := mcdslauth.TokenInfoFromContext(ctx)
actorID := ""
if claims != nil {
actorID = claims.Subject
if info != nil {
actorID = info.Username
}
s.auditFn("repo_deleted", actorID, req.Name, "", "", nil)
}

View File

@@ -2,29 +2,51 @@ package grpcserver
import (
"context"
"net/http/httptest"
"testing"
"google.golang.org/grpc/codes"
"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"
)
// testMCIAS is a package-level variable set by adminDeps for reuse.
var testMCIASSrv *httptest.Server
// adminDeps returns Deps with an admin-capable authenticator and a fresh DB.
func adminDeps(t *testing.T) Deps {
t.Helper()
mcias := mockMCIAS(t)
testMCIASSrv = mcias
auth := testAuthenticator(t, mcias.URL)
return Deps{
DB: openTestDB(t),
Validator: &fakeValidator{
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
},
DB: openTestDB(t),
Authenticator: auth,
}
}
// adminCtx returns a context with an admin bearer token.
func adminCtx() context.Context {
return withAuth(context.Background(), "admin-token")
}
// userCtx returns a context with a regular user bearer token.
func userCtx() context.Context {
return withAuth(context.Background(), "user-token")
}
// adminDepsWithAuthenticator returns Deps using the given authenticator.
func adminDepsWithAuthenticator(t *testing.T, auth *mcdslauth.Authenticator) Deps {
t.Helper()
return Deps{
DB: openTestDB(t),
Authenticator: auth,
}
}
func TestListRepositoriesEmpty(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
@@ -142,3 +164,37 @@ func TestGarbageCollectTrigger(t *testing.T) {
t.Fatal("expected non-empty GC ID")
}
}
func TestListRepositoriesRequiresAuth(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
// No auth token -- should be unauthenticated.
_, err := client.ListRepositories(context.Background(), &pb.ListRepositoriesRequest{})
if err == nil {
t.Fatal("expected error for unauthenticated request")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status, got %v", err)
}
if st.Code() != codes.Unauthenticated {
t.Fatalf("code: got %v, want Unauthenticated", st.Code())
}
}
func TestListRepositoriesAllowsRegularUser(t *testing.T) {
deps := adminDeps(t)
cc := startTestServer(t, deps)
client := pb.NewRegistryServiceClient(cc)
// Regular user should be able to list repositories (auth-required, not admin-required).
resp, err := client.ListRepositories(userCtx(), &pb.ListRepositoriesRequest{})
if err != nil {
t.Fatalf("ListRepositories: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
}

View File

@@ -1,26 +1,23 @@
// Package grpcserver implements the MCR gRPC admin API server.
//
// It provides the same business logic as the REST admin API in
// internal/server/, using shared internal/db and internal/gc packages.
// The server enforces TLS 1.3 minimum, auth via MCIAS token validation,
// and admin role checks on privileged RPCs.
// It delegates TLS, logging, and auth/admin interceptors to the mcdsl
// grpcserver package. MCR-specific business logic lives in the service
// handler files (registry.go, policy.go, audit.go, admin.go).
package grpcserver
import (
"crypto/tls"
"fmt"
"log"
"log/slog"
"net"
"sync"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/db"
"git.wntrmute.dev/kyle/mcr/internal/gc"
"git.wntrmute.dev/kyle/mcr/internal/policy"
"git.wntrmute.dev/kyle/mcr/internal/server"
)
// AuditFunc is a callback for recording audit events. It follows the same
@@ -30,11 +27,11 @@ type AuditFunc func(eventType, actorID, repository, digest, ip string, details m
// Deps holds the dependencies injected into the gRPC server.
type Deps struct {
DB *db.DB
Validator server.TokenValidator
Engine PolicyReloader
AuditFn AuditFunc
Collector *gc.Collector
DB *db.DB
Authenticator *mcdslauth.Authenticator
Engine PolicyReloader
AuditFn AuditFunc
Collector *gc.Collector
}
// PolicyReloader can reload policy rules from a store.
@@ -56,70 +53,47 @@ type gcLastRun struct {
BytesFreed int64
}
// Server wraps a grpc.Server with MCR-specific configuration.
// Server wraps a mcdsl grpcserver.Server with MCR-specific configuration.
type Server struct {
gs *grpc.Server
srv *mcdslgrpc.Server
deps Deps
gcStatus *GCStatus
}
// New creates a configured gRPC server with the interceptor chain:
// [Request Logger] -> [Auth Interceptor] -> [Admin Interceptor] -> [Handler]
// New creates a configured gRPC server that delegates TLS setup, logging,
// and auth/admin interceptors to the mcdsl grpcserver package.
//
// The TLS config enforces TLS 1.3 minimum. If certFile or keyFile is
// empty, the server is created without TLS (for testing only).
func New(certFile, keyFile string, deps Deps) (*Server, error) {
authInt := newAuthInterceptor(deps.Validator)
adminInt := newAdminInterceptor()
chain := grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInt.unary,
adminInt.unary,
)
var opts []grpc.ServerOption
opts = append(opts, chain)
// Configure TLS if cert and key are provided.
if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("grpcserver: load TLS cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
opts = append(opts, grpc.Creds(credentials.NewTLS(tlsCfg)))
// If certFile or keyFile is empty, TLS is skipped (for testing only).
func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, error) {
srv, err := mcdslgrpc.New(certFile, keyFile, deps.Authenticator, methodMap(), logger)
if err != nil {
return nil, err
}
// The JSON codec is registered globally via init() in gen/mcr/v1/codec.go.
// The client must use grpc.ForceCodecV2(mcrv1.JSONCodec{}) to match.
_ = pb.JSONCodec{} // ensure the gen/mcr/v1 init() runs (codec registration)
gs := grpc.NewServer(opts...)
gcStatus := &GCStatus{}
s := &Server{gs: gs, deps: deps, gcStatus: gcStatus}
s := &Server{srv: srv, deps: deps, gcStatus: gcStatus}
// Register all services.
pb.RegisterRegistryServiceServer(gs, &registryService{
pb.RegisterRegistryServiceServer(srv.GRPCServer, &registryService{
db: deps.DB,
collector: deps.Collector,
gcStatus: gcStatus,
auditFn: deps.AuditFn,
})
pb.RegisterPolicyServiceServer(gs, &policyService{
pb.RegisterPolicyServiceServer(srv.GRPCServer, &policyService{
db: deps.DB,
engine: deps.Engine,
auditFn: deps.AuditFn,
})
pb.RegisterAuditServiceServer(gs, &auditService{
pb.RegisterAuditServiceServer(srv.GRPCServer, &auditService{
db: deps.DB,
})
pb.RegisterAdminServiceServer(gs, &adminService{})
pb.RegisterAdminServiceServer(srv.GRPCServer, &adminService{})
return s, nil
}
@@ -127,15 +101,15 @@ func New(certFile, keyFile string, deps Deps) (*Server, error) {
// Serve starts the gRPC server on the given listener.
func (s *Server) Serve(lis net.Listener) error {
log.Printf("grpc server listening on %s", lis.Addr())
return s.gs.Serve(lis)
return s.srv.GRPCServer.Serve(lis)
}
// GracefulStop gracefully stops the gRPC server.
func (s *Server) GracefulStop() {
s.gs.GracefulStop()
s.srv.Stop()
}
// GRPCServer returns the underlying grpc.Server for testing.
func (s *Server) GRPCServer() *grpc.Server {
return s.gs
func (s *Server) GRPCServer() *mcdslgrpc.Server {
return s.srv
}