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:
@@ -162,13 +162,13 @@ func runServer(configPath string) error {
|
|||||||
var grpcLis net.Listener
|
var grpcLis net.Listener
|
||||||
if cfg.Server.GRPCAddr != "" {
|
if cfg.Server.GRPCAddr != "" {
|
||||||
grpcDeps := grpcserver.Deps{
|
grpcDeps := grpcserver.Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: authClient,
|
Authenticator: authClient.Authenticator(),
|
||||||
Engine: policyEngine,
|
Engine: policyEngine,
|
||||||
AuditFn: auditFn,
|
AuditFn: auditFn,
|
||||||
Collector: collector,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create gRPC server: %w", err)
|
return fmt.Errorf("create gRPC server: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ type Client struct {
|
|||||||
auth *mcdslauth.Authenticator
|
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
|
// 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.
|
// serverURL. If caCert is non-empty, it is used as a custom CA cert.
|
||||||
// TLS 1.3 is required for all HTTPS connections.
|
// TLS 1.3 is required for all HTTPS connections.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthReturnsOk(t *testing.T) {
|
func TestHealthReturnsOk(t *testing.T) {
|
||||||
@@ -23,13 +22,13 @@ func TestHealthReturnsOk(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthWithoutAuth(t *testing.T) {
|
func TestHealthWithoutAuth(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
// Use a validator that always rejects.
|
|
||||||
validator := &fakeValidator{err: auth.ErrUnauthorized}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
client := pb.NewAdminServiceClient(cc)
|
client := pb.NewAdminServiceClient(cc)
|
||||||
|
|||||||
@@ -1,165 +1,55 @@
|
|||||||
package grpcserver
|
package grpcserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// authBypassMethods contains the full gRPC method names that bypass
|
// methodMap builds the mcdsl grpcserver.MethodMap for MCR.
|
||||||
// authentication. Health is the only method that does not require auth.
|
//
|
||||||
var authBypassMethods = map[string]bool{
|
// Adding a new RPC without adding it to the correct map is a security
|
||||||
"/mcr.v1.AdminService/Health": true,
|
// 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
|
// publicMethods returns methods that require no authentication.
|
||||||
// the admin role. Adding an RPC without adding it to the correct map is
|
// Health is the only public RPC.
|
||||||
// a security defect per ARCHITECTURE.md.
|
func publicMethods() map[string]bool {
|
||||||
var adminRequiredMethods = map[string]bool{
|
return map[string]bool{
|
||||||
// Registry admin operations.
|
"/mcr.v1.AdminService/Health": true,
|
||||||
"/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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// authInterceptor validates bearer tokens from the authorization metadata.
|
// authRequiredMethods returns methods that require a valid MCIAS token
|
||||||
type authInterceptor struct {
|
// but not the admin role.
|
||||||
validator server.TokenValidator
|
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 {
|
// adminRequiredMethods returns methods that require a valid MCIAS token
|
||||||
return &authInterceptor{validator: v}
|
// with the admin role.
|
||||||
}
|
func adminRequiredMethods() map[string]bool {
|
||||||
|
return map[string]bool{
|
||||||
// unary is the unary server interceptor for auth.
|
// Registry admin operations.
|
||||||
func (a *authInterceptor) unary(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
"/mcr.v1.RegistryService/DeleteRepository": true,
|
||||||
// Health bypasses auth.
|
"/mcr.v1.RegistryService/GarbageCollect": true,
|
||||||
if authBypassMethods[info.FullMethod] {
|
"/mcr.v1.RegistryService/GetGCStatus": true,
|
||||||
return handler(ctx, req)
|
|
||||||
}
|
// Policy management -- all RPCs require admin.
|
||||||
|
"/mcr.v1.PolicyService/ListPolicyRules": true,
|
||||||
// Extract bearer token from authorization metadata.
|
"/mcr.v1.PolicyService/CreatePolicyRule": true,
|
||||||
token, err := extractToken(ctx)
|
"/mcr.v1.PolicyService/GetPolicyRule": true,
|
||||||
if err != nil {
|
"/mcr.v1.PolicyService/UpdatePolicyRule": true,
|
||||||
return nil, status.Errorf(codes.Unauthenticated, "authentication required")
|
"/mcr.v1.PolicyService/DeletePolicyRule": true,
|
||||||
}
|
|
||||||
|
// Audit -- requires admin.
|
||||||
// Validate the token via MCIAS.
|
"/mcr.v1.AuditService/ListAuditEvents": true,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package grpcserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,19 +16,69 @@ import (
|
|||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
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/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeValidator is a test double for server.TokenValidator.
|
// mockMCIAS starts a fake MCIAS HTTP server for token validation.
|
||||||
type fakeValidator struct {
|
// Recognized tokens:
|
||||||
claims *auth.Claims
|
// - "admin-token" → valid, username=admin-uuid, roles=[admin]
|
||||||
err error
|
// - "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) {
|
// testAuthenticator creates an mcdsl/auth.Authenticator that talks to the given mock MCIAS.
|
||||||
return f.claims, f.err
|
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.
|
// 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.
|
// 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 {
|
func startTestServer(t *testing.T, deps Deps) *grpc.ClientConn {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
srv, err := New("", "", deps)
|
srv, err := New("", "", deps, slog.Default())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,12 +135,13 @@ func withAuth(ctx context.Context, token string) context.Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthBypassesAuth(t *testing.T) {
|
func TestHealthBypassesAuth(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{err: auth.ErrUnauthorized}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
client := pb.NewAdminServiceClient(cc)
|
client := pb.NewAdminServiceClient(cc)
|
||||||
@@ -101,12 +155,13 @@ func TestHealthBypassesAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthInterceptorNoToken(t *testing.T) {
|
func TestAuthInterceptorNoToken(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{err: auth.ErrUnauthorized}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
client := pb.NewRegistryServiceClient(cc)
|
client := pb.NewRegistryServiceClient(cc)
|
||||||
@@ -125,12 +180,13 @@ func TestAuthInterceptorNoToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthInterceptorInvalidToken(t *testing.T) {
|
func TestAuthInterceptorInvalidToken(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{err: auth.ErrUnauthorized}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "bad-token")
|
ctx := withAuth(context.Background(), "bad-token")
|
||||||
@@ -150,17 +206,16 @@ func TestAuthInterceptorInvalidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthInterceptorValidToken(t *testing.T) {
|
func TestAuthInterceptorValidToken(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "alice", AccountType: "human", Roles: []string{"user"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "user-token")
|
||||||
client := pb.NewRegistryServiceClient(cc)
|
client := pb.NewRegistryServiceClient(cc)
|
||||||
resp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{})
|
resp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,17 +227,16 @@ func TestAuthInterceptorValidToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminInterceptorDenied(t *testing.T) {
|
func TestAdminInterceptorDenied(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "user-token")
|
||||||
|
|
||||||
// Policy RPCs require admin.
|
// Policy RPCs require admin.
|
||||||
policyClient := pb.NewPolicyServiceClient(cc)
|
policyClient := pb.NewPolicyServiceClient(cc)
|
||||||
@@ -201,17 +255,16 @@ func TestAdminInterceptorDenied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminInterceptorAllowed(t *testing.T) {
|
func TestAdminInterceptorAllowed(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "admin-token")
|
||||||
|
|
||||||
// Admin user should be able to list policy rules.
|
// Admin user should be able to list policy rules.
|
||||||
policyClient := pb.NewPolicyServiceClient(cc)
|
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.
|
// Verify that admin-required methods match our security spec.
|
||||||
// This test catches the security defect of adding an RPC without
|
expectedAdmin := []string{
|
||||||
// adding it to the adminRequiredMethods map.
|
|
||||||
expected := []string{
|
|
||||||
"/mcr.v1.RegistryService/DeleteRepository",
|
"/mcr.v1.RegistryService/DeleteRepository",
|
||||||
"/mcr.v1.RegistryService/GarbageCollect",
|
"/mcr.v1.RegistryService/GarbageCollect",
|
||||||
"/mcr.v1.RegistryService/GetGCStatus",
|
"/mcr.v1.RegistryService/GetGCStatus",
|
||||||
@@ -240,46 +293,59 @@ func TestAdminRequiredMethodsCompleteness(t *testing.T) {
|
|||||||
"/mcr.v1.AuditService/ListAuditEvents",
|
"/mcr.v1.AuditService/ListAuditEvents",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, method := range expected {
|
for _, method := range expectedAdmin {
|
||||||
if !adminRequiredMethods[method] {
|
if !mm.AdminRequired[method] {
|
||||||
t.Errorf("method %s should require admin but is not in adminRequiredMethods", method)
|
t.Errorf("method %s should require admin but is not in AdminRequired", method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(adminRequiredMethods) != len(expected) {
|
if len(mm.AdminRequired) != len(expectedAdmin) {
|
||||||
t.Errorf("adminRequiredMethods has %d entries, expected %d", len(adminRequiredMethods), len(expected))
|
t.Errorf("AdminRequired has %d entries, expected %d", len(mm.AdminRequired), len(expectedAdmin))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthBypassMethodsCompleteness(t *testing.T) {
|
// Health is the only public method.
|
||||||
// Health is the only method that bypasses auth.
|
expectedPublic := []string{
|
||||||
expected := []string{
|
|
||||||
"/mcr.v1.AdminService/Health",
|
"/mcr.v1.AdminService/Health",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, method := range expected {
|
for _, method := range expectedPublic {
|
||||||
if !authBypassMethods[method] {
|
if !mm.Public[method] {
|
||||||
t.Errorf("method %s should bypass auth but is not in authBypassMethods", method)
|
t.Errorf("method %s should be public but is not in Public", method)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(authBypassMethods) != len(expected) {
|
if len(mm.Public) != len(expectedPublic) {
|
||||||
t.Errorf("authBypassMethods has %d entries, expected %d", len(authBypassMethods), len(expected))
|
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) {
|
func TestDeleteRepoRequiresAdmin(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "user-token")
|
||||||
client := pb.NewRegistryServiceClient(cc)
|
client := pb.NewRegistryServiceClient(cc)
|
||||||
_, err := client.DeleteRepository(ctx, &pb.DeleteRepositoryRequest{Name: "test"})
|
_, err := client.DeleteRepository(ctx, &pb.DeleteRepositoryRequest{Name: "test"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -296,17 +362,16 @@ func TestDeleteRepoRequiresAdmin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGCRequiresAdmin(t *testing.T) {
|
func TestGCRequiresAdmin(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "user-token")
|
||||||
client := pb.NewRegistryServiceClient(cc)
|
client := pb.NewRegistryServiceClient(cc)
|
||||||
_, err := client.GarbageCollect(ctx, &pb.GarbageCollectRequest{})
|
_, err := client.GarbageCollect(ctx, &pb.GarbageCollectRequest{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -323,17 +388,16 @@ func TestGCRequiresAdmin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditRequiresAdmin(t *testing.T) {
|
func TestAuditRequiresAdmin(t *testing.T) {
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
database := openTestDB(t)
|
database := openTestDB(t)
|
||||||
validator := &fakeValidator{
|
|
||||||
claims: &auth.Claims{Subject: "user-uuid", AccountType: "human", Roles: []string{"user"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
cc := startTestServer(t, Deps{
|
cc := startTestServer(t, Deps{
|
||||||
DB: database,
|
DB: database,
|
||||||
Validator: validator,
|
Authenticator: auth,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := withAuth(context.Background(), "valid-token")
|
ctx := withAuth(context.Background(), "user-token")
|
||||||
client := pb.NewAuditServiceClient(cc)
|
client := pb.NewAuditServiceClient(cc)
|
||||||
_, err := client.ListAuditEvents(ctx, &pb.ListAuditEventsRequest{})
|
_, err := client.ListAuditEvents(ctx, &pb.ListAuditEventsRequest{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
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/db"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
"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())
|
return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := auth.ClaimsFromContext(ctx)
|
info := mcdslauth.TokenInfoFromContext(ctx)
|
||||||
createdBy := ""
|
createdBy := ""
|
||||||
if claims != nil {
|
if info != nil {
|
||||||
createdBy = claims.Subject
|
createdBy = info.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
row := db.PolicyRuleRow{
|
row := db.PolicyRuleRow{
|
||||||
@@ -207,10 +208,10 @@ func (s *policyService) UpdatePolicyRule(ctx context.Context, req *pb.UpdatePoli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.auditFn != nil {
|
if s.auditFn != nil {
|
||||||
claims := auth.ClaimsFromContext(ctx)
|
info := mcdslauth.TokenInfoFromContext(ctx)
|
||||||
actorID := ""
|
actorID := ""
|
||||||
if claims != nil {
|
if info != nil {
|
||||||
actorID = claims.Subject
|
actorID = info.Username
|
||||||
}
|
}
|
||||||
s.auditFn("policy_rule_updated", actorID, "", "", "", map[string]string{
|
s.auditFn("policy_rule_updated", actorID, "", "", "", map[string]string{
|
||||||
"rule_id": strconv.FormatInt(req.Id, 10),
|
"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 {
|
if s.auditFn != nil {
|
||||||
claims := auth.ClaimsFromContext(ctx)
|
info := mcdslauth.TokenInfoFromContext(ctx)
|
||||||
actorID := ""
|
actorID := ""
|
||||||
if claims != nil {
|
if info != nil {
|
||||||
actorID = claims.Subject
|
actorID = info.Username
|
||||||
}
|
}
|
||||||
s.auditFn("policy_rule_deleted", actorID, "", "", "", map[string]string{
|
s.auditFn("policy_rule_deleted", actorID, "", "", "", map[string]string{
|
||||||
"rule_id": strconv.FormatInt(req.Id, 10),
|
"rule_id": strconv.FormatInt(req.Id, 10),
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
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/db"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/gc"
|
"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 {
|
if s.auditFn != nil {
|
||||||
claims := auth.ClaimsFromContext(ctx)
|
info := mcdslauth.TokenInfoFromContext(ctx)
|
||||||
actorID := ""
|
actorID := ""
|
||||||
if claims != nil {
|
if info != nil {
|
||||||
actorID = claims.Subject
|
actorID = info.Username
|
||||||
}
|
}
|
||||||
s.auditFn("repo_deleted", actorID, req.Name, "", "", nil)
|
s.auditFn("repo_deleted", actorID, req.Name, "", "", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,51 @@ package grpcserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
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 {
|
func adminDeps(t *testing.T) Deps {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
mcias := mockMCIAS(t)
|
||||||
|
testMCIASSrv = mcias
|
||||||
|
auth := testAuthenticator(t, mcias.URL)
|
||||||
return Deps{
|
return Deps{
|
||||||
DB: openTestDB(t),
|
DB: openTestDB(t),
|
||||||
Validator: &fakeValidator{
|
Authenticator: auth,
|
||||||
claims: &auth.Claims{Subject: "admin-uuid", AccountType: "human", Roles: []string{"admin"}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminCtx returns a context with an admin bearer token.
|
||||||
func adminCtx() context.Context {
|
func adminCtx() context.Context {
|
||||||
return withAuth(context.Background(), "admin-token")
|
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) {
|
func TestListRepositoriesEmpty(t *testing.T) {
|
||||||
deps := adminDeps(t)
|
deps := adminDeps(t)
|
||||||
cc := startTestServer(t, deps)
|
cc := startTestServer(t, deps)
|
||||||
@@ -142,3 +164,37 @@ func TestGarbageCollectTrigger(t *testing.T) {
|
|||||||
t.Fatal("expected non-empty GC ID")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
// Package grpcserver implements the MCR gRPC admin API server.
|
// Package grpcserver implements the MCR gRPC admin API server.
|
||||||
//
|
//
|
||||||
// It provides the same business logic as the REST admin API in
|
// It delegates TLS, logging, and auth/admin interceptors to the mcdsl
|
||||||
// internal/server/, using shared internal/db and internal/gc packages.
|
// grpcserver package. MCR-specific business logic lives in the service
|
||||||
// The server enforces TLS 1.3 minimum, auth via MCIAS token validation,
|
// handler files (registry.go, policy.go, audit.go, admin.go).
|
||||||
// and admin role checks on privileged RPCs.
|
|
||||||
package grpcserver
|
package grpcserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
"google.golang.org/grpc/credentials"
|
mcdslgrpc "git.wntrmute.dev/kyle/mcdsl/grpcserver"
|
||||||
|
|
||||||
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
pb "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/gc"
|
"git.wntrmute.dev/kyle/mcr/internal/gc"
|
||||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
"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
|
// 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.
|
// Deps holds the dependencies injected into the gRPC server.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
Validator server.TokenValidator
|
Authenticator *mcdslauth.Authenticator
|
||||||
Engine PolicyReloader
|
Engine PolicyReloader
|
||||||
AuditFn AuditFunc
|
AuditFn AuditFunc
|
||||||
Collector *gc.Collector
|
Collector *gc.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolicyReloader can reload policy rules from a store.
|
// PolicyReloader can reload policy rules from a store.
|
||||||
@@ -56,70 +53,47 @@ type gcLastRun struct {
|
|||||||
BytesFreed int64
|
BytesFreed int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server wraps a grpc.Server with MCR-specific configuration.
|
// Server wraps a mcdsl grpcserver.Server with MCR-specific configuration.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
gs *grpc.Server
|
srv *mcdslgrpc.Server
|
||||||
deps Deps
|
deps Deps
|
||||||
gcStatus *GCStatus
|
gcStatus *GCStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a configured gRPC server with the interceptor chain:
|
// New creates a configured gRPC server that delegates TLS setup, logging,
|
||||||
// [Request Logger] -> [Auth Interceptor] -> [Admin Interceptor] -> [Handler]
|
// and auth/admin interceptors to the mcdsl grpcserver package.
|
||||||
//
|
//
|
||||||
// The TLS config enforces TLS 1.3 minimum. If certFile or keyFile is
|
// If certFile or keyFile is empty, TLS is skipped (for testing only).
|
||||||
// empty, the server is created without TLS (for testing only).
|
func New(certFile, keyFile string, deps Deps, logger *slog.Logger) (*Server, error) {
|
||||||
func New(certFile, keyFile string, deps Deps) (*Server, error) {
|
srv, err := mcdslgrpc.New(certFile, keyFile, deps.Authenticator, methodMap(), logger)
|
||||||
authInt := newAuthInterceptor(deps.Validator)
|
if err != nil {
|
||||||
adminInt := newAdminInterceptor()
|
return nil, err
|
||||||
|
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The JSON codec is registered globally via init() in gen/mcr/v1/codec.go.
|
// The JSON codec is registered globally via init() in gen/mcr/v1/codec.go.
|
||||||
// The client must use grpc.ForceCodecV2(mcrv1.JSONCodec{}) to match.
|
// The client must use grpc.ForceCodecV2(mcrv1.JSONCodec{}) to match.
|
||||||
_ = pb.JSONCodec{} // ensure the gen/mcr/v1 init() runs (codec registration)
|
_ = pb.JSONCodec{} // ensure the gen/mcr/v1 init() runs (codec registration)
|
||||||
|
|
||||||
gs := grpc.NewServer(opts...)
|
|
||||||
|
|
||||||
gcStatus := &GCStatus{}
|
gcStatus := &GCStatus{}
|
||||||
|
|
||||||
s := &Server{gs: gs, deps: deps, gcStatus: gcStatus}
|
s := &Server{srv: srv, deps: deps, gcStatus: gcStatus}
|
||||||
|
|
||||||
// Register all services.
|
// Register all services.
|
||||||
pb.RegisterRegistryServiceServer(gs, ®istryService{
|
pb.RegisterRegistryServiceServer(srv.GRPCServer, ®istryService{
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
collector: deps.Collector,
|
collector: deps.Collector,
|
||||||
gcStatus: gcStatus,
|
gcStatus: gcStatus,
|
||||||
auditFn: deps.AuditFn,
|
auditFn: deps.AuditFn,
|
||||||
})
|
})
|
||||||
pb.RegisterPolicyServiceServer(gs, &policyService{
|
pb.RegisterPolicyServiceServer(srv.GRPCServer, &policyService{
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
engine: deps.Engine,
|
engine: deps.Engine,
|
||||||
auditFn: deps.AuditFn,
|
auditFn: deps.AuditFn,
|
||||||
})
|
})
|
||||||
pb.RegisterAuditServiceServer(gs, &auditService{
|
pb.RegisterAuditServiceServer(srv.GRPCServer, &auditService{
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
})
|
})
|
||||||
pb.RegisterAdminServiceServer(gs, &adminService{})
|
pb.RegisterAdminServiceServer(srv.GRPCServer, &adminService{})
|
||||||
|
|
||||||
return s, nil
|
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.
|
// Serve starts the gRPC server on the given listener.
|
||||||
func (s *Server) Serve(lis net.Listener) error {
|
func (s *Server) Serve(lis net.Listener) error {
|
||||||
log.Printf("grpc server listening on %s", lis.Addr())
|
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.
|
// GracefulStop gracefully stops the gRPC server.
|
||||||
func (s *Server) GracefulStop() {
|
func (s *Server) GracefulStop() {
|
||||||
s.gs.GracefulStop()
|
s.srv.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GRPCServer returns the underlying grpc.Server for testing.
|
// GRPCServer returns the underlying grpc.Server for testing.
|
||||||
func (s *Server) GRPCServer() *grpc.Server {
|
func (s *Server) GRPCServer() *mcdslgrpc.Server {
|
||||||
return s.gs
|
return s.srv
|
||||||
}
|
}
|
||||||
|
|||||||
192
vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go
vendored
Normal file
192
vendor/git.wntrmute.dev/kyle/mcdsl/grpcserver/server.go
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
1
vendor/modules.txt
vendored
1
vendor/modules.txt
vendored
@@ -3,6 +3,7 @@
|
|||||||
git.wntrmute.dev/kyle/mcdsl/auth
|
git.wntrmute.dev/kyle/mcdsl/auth
|
||||||
git.wntrmute.dev/kyle/mcdsl/config
|
git.wntrmute.dev/kyle/mcdsl/config
|
||||||
git.wntrmute.dev/kyle/mcdsl/db
|
git.wntrmute.dev/kyle/mcdsl/db
|
||||||
|
git.wntrmute.dev/kyle/mcdsl/grpcserver
|
||||||
# github.com/dustin/go-humanize v1.0.1
|
# github.com/dustin/go-humanize v1.0.1
|
||||||
## explicit; go 1.16
|
## explicit; go 1.16
|
||||||
github.com/dustin/go-humanize
|
github.com/dustin/go-humanize
|
||||||
|
|||||||
Reference in New Issue
Block a user