2 Commits

Author SHA1 Message Date
86d516acf6 Drop admin requirement from agent interceptor, reject guests
The agent now accepts any authenticated user or system account, except
those with the guest role. Admin is reserved for MCIAS account management
and policy changes, not routine deploy/stop/start operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:07:17 -07:00
dd167b8e0b Auto-login to MCR before image push using CLI token
mcp build and mcp deploy (auto-build path) now authenticate to the
container registry using the CLI's stored MCIAS token before pushing.
MCR accepts JWTs as passwords, so this works with both human and
service account tokens. Falls back silently to existing podman auth.

Eliminates the need for a separate interactive `podman login` step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:13:35 -07:00
4 changed files with 80 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config" "git.wntrmute.dev/mc/mcp/internal/config"
"git.wntrmute.dev/mc/mcp/internal/runtime" "git.wntrmute.dev/mc/mcp/internal/runtime"
"git.wntrmute.dev/mc/mcp/internal/servicedef" "git.wntrmute.dev/mc/mcp/internal/servicedef"
@@ -52,6 +53,17 @@ func buildServiceImages(ctx context.Context, cfg *config.CLIConfig, def *service
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path) sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
// Auto-login to the registry using the CLI's stored MCIAS token.
// MCR accepts JWTs as passwords, so this works for both human and
// service account tokens. Failures are non-fatal — existing podman
// auth may suffice.
if token, err := auth.LoadToken(cfg.Auth.TokenPath); err == nil && token != "" {
registry := extractRegistry(def)
if registry != "" {
_ = rt.Login(ctx, registry, "mcp", token)
}
}
for imageName, dockerfile := range def.Build.Images { for imageName, dockerfile := range def.Build.Images {
if imageFilter != "" && imageName != imageFilter { if imageFilter != "" && imageName != imageFilter {
continue continue
@@ -96,6 +108,19 @@ func findImageRef(def *servicedef.ServiceDef, imageName string) string {
return "" return ""
} }
// extractRegistry returns the registry host from the first component's
// image reference (e.g., "mcr.svc.mcp.metacircular.net:8443" from
// "mcr.svc.mcp.metacircular.net:8443/mcq:v0.1.1"). Returns empty
// string if no slash is found.
func extractRegistry(def *servicedef.ServiceDef) string {
for _, c := range def.Components {
if i := strings.LastIndex(c.Image, "/"); i > 0 {
return c.Image[:i]
}
}
return ""
}
// extractRepoName returns the repository name from an image reference. // extractRepoName returns the repository name from an image reference.
// Examples: // Examples:
// //
@@ -124,6 +149,8 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
return nil // no build config, skip auto-build return nil // no build config, skip auto-build
} }
registryLoginDone := false
for _, c := range def.Components { for _, c := range def.Components {
if component != "" && c.Name != component { if component != "" && c.Name != component {
continue continue
@@ -153,6 +180,17 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
sourceDir := filepath.Join(cfg.Build.Workspace, def.Path) sourceDir := filepath.Join(cfg.Build.Workspace, def.Path)
// Auto-login to registry before first push.
if !registryLoginDone {
if token, err := auth.LoadToken(cfg.Auth.TokenPath); err == nil && token != "" {
registry := extractRegistry(def)
if registry != "" {
_ = rt.Login(ctx, registry, "mcp", token)
}
}
registryLoginDone = true
}
fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile) fmt.Printf("image %s not found, building from %s\n", c.Image, dockerfile)
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil { if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
return fmt.Errorf("auto-build %s: %w", c.Image, err) return fmt.Errorf("auto-build %s: %w", c.Image, err)

View File

@@ -206,7 +206,10 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo {
} }
// AuthInterceptor returns a gRPC unary server interceptor that validates // AuthInterceptor returns a gRPC unary server interceptor that validates
// bearer tokens and requires the "admin" role. // bearer tokens. Any authenticated user or system account is accepted,
// except guests which are explicitly rejected. Admin role is not required
// for agent operations — it is reserved for MCIAS account management and
// policy changes.
func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor { func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
return func( return func(
ctx context.Context, ctx context.Context,
@@ -240,9 +243,9 @@ func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
return nil, status.Error(codes.Unauthenticated, "invalid token") return nil, status.Error(codes.Unauthenticated, "invalid token")
} }
if !tokenInfo.HasRole("admin") { if tokenInfo.HasRole("guest") {
slog.Warn("permission denied", "method", info.FullMethod, "user", tokenInfo.Username) slog.Warn("guest access denied", "method", info.FullMethod, "user", tokenInfo.Username)
return nil, status.Error(codes.PermissionDenied, "admin role required") return nil, status.Error(codes.PermissionDenied, "guest access not permitted")
} }
slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType) slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType)

View File

@@ -126,7 +126,7 @@ func TestInterceptorRejectsInvalidToken(t *testing.T) {
} }
} }
func TestInterceptorRejectsNonAdmin(t *testing.T) { func TestInterceptorAcceptsRegularUser(t *testing.T) {
server := mockMCIAS(t, func(authHeader string) (any, int) { server := mockMCIAS(t, func(authHeader string) (any, int) {
return &TokenInfo{ return &TokenInfo{
Valid: true, Valid: true,
@@ -142,6 +142,28 @@ func TestInterceptorRejectsNonAdmin(t *testing.T) {
md := metadata.Pairs("authorization", "Bearer user-token") md := metadata.Pairs("authorization", "Bearer user-token")
ctx := metadata.NewIncomingContext(context.Background(), md) ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := callInterceptor(ctx, v)
if err != nil {
t.Fatalf("expected regular user to be accepted, got %v", err)
}
}
func TestInterceptorRejectsGuest(t *testing.T) {
server := mockMCIAS(t, func(authHeader string) (any, int) {
return &TokenInfo{
Valid: true,
Username: "visitor",
Roles: []string{"guest"},
AccountType: "human",
}, http.StatusOK
})
defer server.Close()
v := validatorFromServer(t, server)
md := metadata.Pairs("authorization", "Bearer guest-token")
ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := callInterceptor(ctx, v) _, err := callInterceptor(ctx, v)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")

View File

@@ -178,6 +178,18 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
return info, nil return info, nil
} }
// Login authenticates to a container registry using the given token as
// the password. This enables non-interactive push with service account
// tokens (MCR accepts MCIAS JWTs as passwords).
func (p *Podman) Login(ctx context.Context, registry, username, token string) error {
cmd := exec.CommandContext(ctx, p.command(), "login", "--username", username, "--password-stdin", registry) //nolint:gosec // args built programmatically
cmd.Stdin = strings.NewReader(token)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman login %q: %w: %s", registry, err, out)
}
return nil
}
// Build builds a container image from a Dockerfile. // Build builds a container image from a Dockerfile.
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error { func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
args := []string{"build", "-t", image, "-f", dockerfile, contextDir} args := []string{"build", "-t", image, "-f", dockerfile, contextDir}