Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d516acf6 | |||
| dd167b8e0b |
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wntrmute.dev/mc/mcp/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcp/internal/config"
|
||||
"git.wntrmute.dev/mc/mcp/internal/runtime"
|
||||
"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)
|
||||
|
||||
// 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 {
|
||||
if imageFilter != "" && imageName != imageFilter {
|
||||
continue
|
||||
@@ -96,6 +108,19 @@ func findImageRef(def *servicedef.ServiceDef, imageName string) string {
|
||||
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.
|
||||
// Examples:
|
||||
//
|
||||
@@ -124,6 +149,8 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
|
||||
return nil // no build config, skip auto-build
|
||||
}
|
||||
|
||||
registryLoginDone := false
|
||||
|
||||
for _, c := range def.Components {
|
||||
if component != "" && c.Name != component {
|
||||
continue
|
||||
@@ -153,6 +180,17 @@ func ensureImages(ctx context.Context, cfg *config.CLIConfig, def *servicedef.Se
|
||||
|
||||
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)
|
||||
if err := rt.Build(ctx, c.Image, sourceDir, dockerfile); err != nil {
|
||||
return fmt.Errorf("auto-build %s: %w", c.Image, err)
|
||||
|
||||
@@ -206,7 +206,10 @@ func TokenInfoFromContext(ctx context.Context) *TokenInfo {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
@@ -240,9 +243,9 @@ func AuthInterceptor(validator TokenValidator) grpc.UnaryServerInterceptor {
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid token")
|
||||
}
|
||||
|
||||
if !tokenInfo.HasRole("admin") {
|
||||
slog.Warn("permission denied", "method", info.FullMethod, "user", tokenInfo.Username)
|
||||
return nil, status.Error(codes.PermissionDenied, "admin role required")
|
||||
if tokenInfo.HasRole("guest") {
|
||||
slog.Warn("guest access denied", "method", info.FullMethod, "user", tokenInfo.Username)
|
||||
return nil, status.Error(codes.PermissionDenied, "guest access not permitted")
|
||||
}
|
||||
|
||||
slog.Info("rpc", "method", info.FullMethod, "user", tokenInfo.Username, "account_type", tokenInfo.AccountType)
|
||||
|
||||
@@ -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) {
|
||||
return &TokenInfo{
|
||||
Valid: true,
|
||||
@@ -142,6 +142,28 @@ func TestInterceptorRejectsNonAdmin(t *testing.T) {
|
||||
md := metadata.Pairs("authorization", "Bearer user-token")
|
||||
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)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
|
||||
@@ -178,6 +178,18 @@ func (p *Podman) Inspect(ctx context.Context, name string) (ContainerInfo, error
|
||||
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.
|
||||
func (p *Podman) Build(ctx context.Context, image, contextDir, dockerfile string) error {
|
||||
args := []string{"build", "-t", image, "-f", dockerfile, contextDir}
|
||||
|
||||
Reference in New Issue
Block a user