Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d516acf6 | |||
| dd167b8e0b |
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user