Ctrl K
ComunidadGuías…

internal/mcptools/principal.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcptools

import (
	"context"
	"errors"
	"log"
	"strconv"
	"xprem/internal/repository"
	"xprem/internal/services"
	"xprem/internal/validation"

	mcpprot "github.com/modelcontextprotocol/go-sdk/mcp"
)

// PrincipalFromRequest resolves who this tool call acts as; exported for the
// ee tool package. The handler's ctx derives from the session, not from the
// HTTP request, so the per-request identity travels in the request's
// TokenInfo, where the OAuth middleware's verifier stored it.
func PrincipalFromRequest(req *mcpprot.CallToolRequest) *services.DashboardPrincipal {
	if req == nil || req.Extra == nil || req.Extra.TokenInfo == nil {
		return nil
	}
	return services.PrincipalFromExtra(req.Extra.TokenInfo.Extra)
}

// ErrAppNotFound reads like a 404 on purpose, mirroring the rbac semantics: a
// member without access must not learn the app exists.
var ErrAppNotFound = errors.New("app not found")

// boolPtr is for the SDK's optional tool hints.
func boolPtr(value bool) *bool {
	return &value
}

// requireAppPermission is the gate of the app-scoped write tools: it resolves
// the caller and authorizes the action on that app, the same decision the
// route twin takes. Visibility of the tool itself is decided at session
// creation; this is the one that protects the action.
// It returns a context carrying the principal: the services resolve their
// audit actor from it (services.PrincipalFromContext), so without this the
// mutations would be recorded with an empty actor.
func requireAppPermission(ctx context.Context, deps Deps, req *mcpprot.CallToolRequest, appID string, access Access) (context.Context, *services.DashboardPrincipal, error) {
	principal := PrincipalFromRequest(req)
	if principal == nil {
		return ctx, nil, errors.New("no authenticated account on this session")
	}
	// Existence then authorization, the order the dashboard subrouter uses
	// (AppResolverMiddleware, then rbac). Visibility is deliberately NOT
	// checked here: Authorize already answers ErrNoAppAccess ("app not
	// found") for a member with no grant on the app, so asking VisibleApps
	// first would be the same decision at the cost of two more reads.
	if err := appExists(ctx, deps.Apps, appID); err != nil {
		return ctx, nil, err
	}
	if err := deps.Authorize(ctx, principal, appID, access); err != nil {
		return ctx, nil, err
	}
	return services.WithPrincipal(ctx, principal), principal, nil
}

// writeError keeps a write failure readable: the service and store messages
// are actionable (protected branch, active rollout, unknown update), so they
// reach the caller as-is, while unexpected failures are logged and masked.
func writeError(err error, action string, logPrefix string, principal *services.DashboardPrincipal, appID string) error {
	if isActionableWriteError(err) {
		return err
	}
	log.Printf("%s failed for user %s on app %s: %v", logPrefix, principal.UserId, appID, err)
	return errors.New("could not " + action + ", try again later")
}

// requireBranchRuntimeVersion refuses a publish target that does not exist,
// with a message naming what to fix. Without it the insert resolves the pair
// to nothing and fails on a not-null constraint, which reads as an internal
// error and tells the caller nothing (a branch id passed as a name lands
// exactly there).
func requireBranchRuntimeVersion(ctx context.Context, deps Deps, appId string, branchName string, runtimeVersion string) error {
	branches, err := deps.Branches.GetBranches(ctx, appId)
	if err != nil {
		log.Printf("mcp tools could not list the branches of app %s: %v", appId, err)
		return errors.New("could not check the branch, try again later")
	}
	found := false
	for _, branch := range branches {
		if branch.BranchName == branchName {
			found = true
			break
		}
	}
	if !found {
		return errors.New("no branch named " + strconv.Quote(branchName) + " on this app; list the branches with get_branches and pass the branch NAME")
	}
	versions, err := deps.Branches.GetRuntimeVersionsWithUpdateStats(ctx, appId, branchName)
	if err != nil {
		log.Printf("mcp tools could not list the runtime versions of app %s branch %s: %v", appId, branchName, err)
		return errors.New("could not check the runtime version, try again later")
	}
	for _, version := range versions {
		if version.RuntimeVersion == runtimeVersion {
			return nil
		}
	}
	return errors.New("branch " + strconv.Quote(branchName) + " has no runtime version " + strconv.Quote(runtimeVersion) + "; list them with get_runtime_versions")
}

// isActionableWriteError reports whether a write failure tells the caller
// something it can act on, in which case its message is safe and useful to
// return verbatim. Everything else is infrastructure and gets masked.
func isActionableWriteError(err error) bool {
	var (
		validationErr     *validation.Error
		notFound          *repository.ErrResourceNotFound
		alreadyExists     *repository.ErrResourceAlreadyExists
		branchProtected   *repository.ErrBranchProtected
		branchHasChannels *repository.ErrBranchHasActiveChannels
		branchInRollout   *repository.ErrBranchInActiveRollout
		channelHasRollout *repository.ErrChannelHasActiveRollout
		republishErr      *services.RepublishError
	)
	switch {
	case errors.As(err, &validationErr),
		errors.As(err, &notFound),
		errors.As(err, &alreadyExists),
		errors.As(err, &branchProtected),
		errors.As(err, &branchHasChannels),
		errors.As(err, &branchInRollout),
		errors.As(err, &channelHasRollout),
		errors.As(err, &republishErr):
		return true
	case errors.Is(err, services.ErrActiveRolloutBlocksPublish),
		errors.Is(err, services.ErrRolloutSuperseded),
		errors.Is(err, services.ErrPublishGroupNotFound),
		errors.Is(err, services.ErrNoChangesDetected),
		errors.Is(err, repository.ErrNotSupportedInStatelessMode):
		return true
	}
	return false
}

func requireAppVisible(ctx context.Context, deps Deps, principal *services.DashboardPrincipal, appID string) error {
	return RequireAppAccess(ctx, principal, appID, deps.Apps, deps.VisibleApps)
}

// RequireAppAccess gates the app-scoped tools: the app must exist and the
// account must be allowed to see it. Both halves matter: VisibleApps answers
// restricted=false for admins and for deployments without enforced roles, so
// on its own it lets an unknown app id through to the services, where a
// nonexistent app reads as an empty result or a constraint error instead of
// "no such app". It takes its two dependencies rather than a Deps so the ee
// tool package shares this one implementation of the answer.
func RequireAppAccess(ctx context.Context, principal *services.DashboardPrincipal, appID string, apps AppLister, visibleApps AppVisibilityFunc) error {
	if appID == "" {
		return errors.New("appId is required; list the apps with get_apps")
	}
	restricted, visible, err := visibleApps(ctx, principal)
	if err != nil {
		log.Printf("mcp tools could not resolve the visible apps of user %s: %v", principal.UserId, err)
		return errors.New("could not check the app access, try again later")
	}
	if restricted {
		// A visible app is an app the account holds a grant on, so it exists:
		// nothing more to read.
		if !visible[appID] {
			return ErrAppNotFound
		}
		return nil
	}
	// Unrestricted scope (an admin, or a deployment without enforced roles):
	// the check above looked at nothing, so an unknown id would reach the
	// services and read as an empty result or a constraint error.
	return appExists(ctx, apps, appID)
}

// appExists is the tool-layer twin of the dashboard's AppResolverMiddleware.
// It answers like an app the account may not see, so the two cases stay
// indistinguishable.
func appExists(ctx context.Context, apps AppLister, appID string) error {
	if appID == "" {
		return errors.New("appId is required; list the apps with get_apps")
	}
	descriptors, err := apps.GetApps(ctx)
	if err != nil {
		log.Printf("mcp tools could not list the apps: %v", err)
		return errors.New("could not check the app, try again later")
	}
	for _, descriptor := range descriptors {
		if descriptor.Id == appID {
			return nil
		}
	}
	return ErrAppNotFound
}