diff --git a/cmd/mcrsrv/main.go b/cmd/mcrsrv/main.go index a6a4b16..70b5dfd 100644 --- a/cmd/mcrsrv/main.go +++ b/cmd/mcrsrv/main.go @@ -162,13 +162,13 @@ func runServer(configPath string) error { var grpcLis net.Listener if cfg.Server.GRPCAddr != "" { grpcDeps := grpcserver.Deps{ - DB: database, - Validator: authClient, - Engine: policyEngine, - AuditFn: auditFn, - Collector: collector, + DB: database, + Authenticator: authClient.Authenticator(), + Engine: policyEngine, + AuditFn: auditFn, + Collector: collector, } - grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcDeps) + grpcSrv, err = grpcserver.New(cfg.Server.TLSCert, cfg.Server.TLSKey, grpcDeps, logger) if err != nil { return fmt.Errorf("create gRPC server: %w", err) } diff --git a/internal/auth/client.go b/internal/auth/client.go index a11f865..1f8ef3c 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -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. diff --git a/internal/grpcserver/admin_test.go b/internal/grpcserver/admin_test.go index 7cf67da..8261458 100644 --- a/internal/grpcserver/admin_test.go +++ b/internal/grpcserver/admin_test.go @@ -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) diff --git a/internal/grpcserver/interceptors.go b/internal/grpcserver/interceptors.go index 0cb718e..0781b0a 100644 --- a/internal/grpcserver/interceptors.go +++ b/internal/grpcserver/interceptors.go @@ -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, + } } diff --git a/internal/grpcserver/interceptors_test.go b/internal/grpcserver/interceptors_test.go index c5091da..48fc19e 100644 --- a/internal/grpcserver/interceptors_test.go +++ b/internal/grpcserver/interceptors_test.go @@ -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 { diff --git a/internal/grpcserver/policy.go b/internal/grpcserver/policy.go index 0b81f42..9558ada 100644 --- a/internal/grpcserver/policy.go +++ b/internal/grpcserver/policy.go @@ -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), diff --git a/internal/grpcserver/registry.go b/internal/grpcserver/registry.go index dd90f50..408544a 100644 --- a/internal/grpcserver/registry.go +++ b/internal/grpcserver/registry.go @@ -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) } diff --git a/internal/grpcserver/registry_test.go b/internal/grpcserver/registry_test.go index 6667ca1..e931aec 100644 --- a/internal/grpcserver/registry_test.go +++ b/internal/grpcserver/registry_test.go @@ -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") + } +} diff --git a/internal/grpcserver/server.go b/internal/grpcserver/server.go index 1130a76..ee729bc 100644 --- a/internal/grpcserver/server.go +++ b/internal/grpcserver/server.go @@ -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, ®istryService{ + pb.RegisterRegistryServiceServer(srv.GRPCServer, ®istryService{ 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 } diff --git a/vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go b/vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go new file mode 100644 index 0000000..2848cae --- /dev/null +++ b/vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go @@ -0,0 +1,192 @@ +// Package grpcserver provides gRPC server setup with TLS, interceptor +// chain, and method-map authentication for Metacircular services. +// +// Access control is enforced via a [MethodMap] that classifies each RPC +// as public, auth-required, or admin-required. Methods not listed in any +// map are denied by default — forgetting to register a new RPC results +// in a denied request, not an open one. +package grpcserver + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "git.wntrmute.dev/kyle/mcdsl/auth" +) + +// MethodMap classifies gRPC methods for access control. +type MethodMap struct { + // Public methods require no authentication. + Public map[string]bool + + // AuthRequired methods require a valid MCIAS bearer token. + AuthRequired map[string]bool + + // AdminRequired methods require a valid token with the admin role. + AdminRequired map[string]bool +} + +// Server wraps a grpc.Server with Metacircular auth interceptors. +type Server struct { + // GRPCServer is the underlying grpc.Server. Services register their + // implementations on it before calling Serve. + GRPCServer *grpc.Server + + // Logger is used by the logging interceptor. + Logger *slog.Logger + + listener net.Listener +} + +// New creates a gRPC server with TLS (if certFile and keyFile are +// non-empty) and an interceptor chain: logging → auth → handler. +// +// The auth interceptor uses methods to determine the access level for +// each RPC. Methods not in any map are denied by default. +// +// If certFile and keyFile are empty, TLS is skipped (for testing). +func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger) (*Server, error) { + chain := grpc.ChainUnaryInterceptor( + loggingInterceptor(logger), + authInterceptor(authenticator, methods), + ) + + var opts []grpc.ServerOption + opts = append(opts, chain) + + 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))) + } + + return &Server{ + GRPCServer: grpc.NewServer(opts...), + Logger: logger, + }, nil +} + +// Serve starts the gRPC server on the given address. It blocks until +// the server is stopped. +func (s *Server) Serve(addr string) error { + lis, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("grpcserver: listen %s: %w", addr, err) + } + s.listener = lis + s.Logger.Info("starting gRPC server", "addr", addr) + return s.GRPCServer.Serve(lis) +} + +// Stop gracefully stops the gRPC server, waiting for in-flight RPCs +// to complete. +func (s *Server) Stop() { + s.GRPCServer.GracefulStop() +} + +// TokenInfoFromContext extracts [auth.TokenInfo] from a gRPC request +// context. Returns nil if no token info is present (e.g., for public +// methods). +func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo { + return auth.TokenInfoFromContext(ctx) +} + +// loggingInterceptor logs each RPC after it completes. +func loggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + start := time.Now() + resp, err := handler(ctx, req) + code := status.Code(err) + logger.Info("grpc", + "method", info.FullMethod, + "code", code.String(), + "duration", time.Since(start), + ) + return resp, err + } +} + +// authInterceptor enforces access control based on the MethodMap. +// +// Evaluation order: +// 1. Public → pass through, no auth. +// 2. AdminRequired → validate token, require IsAdmin. +// 3. AuthRequired → validate token. +// 4. Not in any map → deny (default deny). +func authInterceptor(authenticator *auth.Authenticator, methods MethodMap) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + method := info.FullMethod + + // Public methods: no auth. + if methods.Public[method] { + return handler(ctx, req) + } + + // All other methods require a valid token. + tokenInfo, err := extractAndValidate(ctx, authenticator) + if err != nil { + return nil, err + } + + // Admin-required methods: check admin role. + if methods.AdminRequired[method] { + if !tokenInfo.IsAdmin { + return nil, status.Errorf(codes.PermissionDenied, "admin role required") + } + ctx = auth.ContextWithTokenInfo(ctx, tokenInfo) + return handler(ctx, req) + } + + // Auth-required methods: token is sufficient. + if methods.AuthRequired[method] { + ctx = auth.ContextWithTokenInfo(ctx, tokenInfo) + return handler(ctx, req) + } + + // Default deny: method not in any map. + return nil, status.Errorf(codes.PermissionDenied, "method not authorized") + } +} + +// extractAndValidate extracts the bearer token from gRPC metadata and +// validates it via the Authenticator. +func extractAndValidate(ctx context.Context, authenticator *auth.Authenticator) (*auth.TokenInfo, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "missing metadata") + } + + vals := md.Get("authorization") + if len(vals) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "missing authorization header") + } + + token := vals[0] + const bearerPrefix = "Bearer " + if len(token) > len(bearerPrefix) && token[:len(bearerPrefix)] == bearerPrefix { + token = token[len(bearerPrefix):] + } + + info, err := authenticator.ValidateToken(token) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token") + } + + return info, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 764c87a..73fcece 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -3,6 +3,7 @@ git.wntrmute.dev/kyle/mcdsl/auth git.wntrmute.dev/kyle/mcdsl/config git.wntrmute.dev/kyle/mcdsl/db +git.wntrmute.dev/kyle/mcdsl/grpcserver # github.com/dustin/go-humanize v1.0.1 ## explicit; go 1.16 github.com/dustin/go-humanize