Files
mcr/cmd/mcr-web/main.go
Kyle Isom 9d7043a594 Block guest accounts from web UI login
The web UI now validates the MCIAS token after login and rejects
accounts with the guest role before setting the session cookie.
This is defense-in-depth alongside the env:restricted MCIAS tag.

The webserver.New() constructor takes a new ValidateFunc parameter
that inspects token roles post-authentication. MCIAS login does not
return roles, so this requires an extra ValidateToken round-trip at
login time (result is cached for 30s).

Security: guest role accounts are denied web UI access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:02:22 -07:00

200 lines
4.8 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
"git.wntrmute.dev/kyle/mcr/internal/auth"
"git.wntrmute.dev/kyle/mcr/internal/config"
"git.wntrmute.dev/kyle/mcr/internal/webserver"
)
var version = "dev"
func main() {
root := &cobra.Command{
Use: "mcr-web",
Short: "Metacircular Container Registry web UI",
Version: version,
}
root.AddCommand(serverCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func serverCmd() *cobra.Command {
var configPath string
cmd := &cobra.Command{
Use: "server",
Short: "Start the web UI server",
RunE: func(_ *cobra.Command, _ []string) error {
return runServer(configPath)
},
}
cmd.Flags().StringVar(&configPath, "config", "", "path to mcr.toml config file (required)")
if err := cmd.MarkFlagRequired("config"); err != nil {
log.Fatalf("flag setup: %v", err)
}
return cmd
}
func runServer(configPath string) error {
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Web.ListenAddr == "" {
return fmt.Errorf("config: web.listen_addr is required")
}
if cfg.Web.GRPCAddr == "" {
return fmt.Errorf("config: web.grpc_addr is required")
}
// Connect to mcrsrv gRPC API.
grpcConn, err := dialGRPC(cfg)
if err != nil {
return fmt.Errorf("connect to gRPC server: %w", err)
}
defer func() { _ = grpcConn.Close() }()
// Create gRPC clients.
registryClient := mcrv1.NewRegistryServiceClient(grpcConn)
policyClient := mcrv1.NewPolicyServiceClient(grpcConn)
auditClient := mcrv1.NewAuditServiceClient(grpcConn)
adminClient := mcrv1.NewAdminServiceClient(grpcConn)
// Create MCIAS auth client for login.
authClient, err := auth.NewClient(
cfg.MCIAS.ServerURL,
cfg.MCIAS.CACert,
cfg.MCIAS.ServiceName,
cfg.MCIAS.Tags,
)
if err != nil {
return fmt.Errorf("create auth client: %w", err)
}
loginFn := func(username, password string) (string, int, error) {
return authClient.Login(username, password)
}
validateFn := func(token string) ([]string, error) {
claims, err := authClient.ValidateToken(token)
if err != nil {
return nil, err
}
return claims.Roles, nil
}
// Generate CSRF key.
csrfKey := make([]byte, 32)
if _, err := rand.Read(csrfKey); err != nil {
return fmt.Errorf("generate CSRF key: %w", err)
}
// Create web server.
srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey)
if err != nil {
return fmt.Errorf("create web server: %w", err)
}
// Configure TLS.
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
cert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("load TLS certificate: %w", err)
}
tlsCfg.Certificates = []tls.Certificate{cert}
httpServer := &http.Server{
Addr: cfg.Web.ListenAddr,
Handler: srv.Handler(),
TLSConfig: tlsCfg,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on SIGINT/SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
log.Printf("mcr-web listening on %s", cfg.Web.ListenAddr)
// TLS is configured via TLSConfig, so use ServeTLS with empty cert/key
// paths (they are already loaded).
errCh <- httpServer.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
return fmt.Errorf("server error: %w", err)
case <-ctx.Done():
log.Println("shutting down mcr-web...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Println("mcr-web stopped")
return nil
}
}
// dialGRPC establishes a gRPC connection to the mcrsrv API server.
func dialGRPC(cfg *config.Config) (*grpc.ClientConn, error) {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.Web.CACert != "" {
pem, err := os.ReadFile(cfg.Web.CACert) //nolint:gosec // CA cert path is operator-supplied
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no valid certificates in CA cert file")
}
tlsCfg.RootCAs = pool
}
creds := credentials.NewTLS(tlsCfg)
conn, err := grpc.NewClient(
cfg.Web.GRPCAddr,
grpc.WithTransportCredentials(creds),
grpc.WithDefaultCallOptions(grpc.ForceCodecV2(mcrv1.JSONCodec{})),
)
if err != nil {
return nil, err
}
return conn, nil
}