Files
kandev/apps/backend/internal/backendapp/canvas_runtime_routes.go
T
2026-09-23 20:51:55 +01:00

293 lines
10 KiB
Go

package backendapp
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
canvasservice "github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/webapp"
userstore "github.com/kandev/kandev/internal/user/store"
)
var errCanvasWebAppNotDeclared = errors.New("canvas web application is not declared")
func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.Canvas, request canvasRuntimeRequest) (string, instances.Release, manifest.WebApp, webapp.CapabilityBinding, error) {
instance, release, appManifest, err := h.loadRuntimeRelease(c, canvas)
if err != nil {
return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err
}
app, placement, err := selectRuntimeWebApp(appManifest, request, canvas.ScopeKind)
if err != nil {
return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err
}
grants, err := h.plugins.Instances().ListGrants(c.Request.Context(), instance.ID)
if err != nil {
return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err
}
binding := webapp.CapabilityBinding{
PluginID: instance.PluginID,
UserID: canvasRuntimeUser(c),
InstanceID: instance.ID,
ReleaseID: release.ID,
WebAppKey: app.Key,
Placement: placement,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
WorkspaceID: instance.WorkspaceID,
TaskID: instance.TaskID,
SessionID: instance.SessionID,
RepositoryID: instance.RepositoryID,
GrantGeneration: instance.GrantGeneration,
Permissions: runtimeGrantedPermissions(appManifest, instance.EffectiveDataScopeKind(), grants),
Artifact: webapp.Artifact{
Digest: release.PackageDigest,
RelativePath: release.ArtifactPath,
Bytes: release.ArtifactBytes,
Available: true,
},
Entry: app.Entry,
NetworkOrigins: runtimeNetworkOrigins(app, instance.EffectiveDataScopeKind(), grants),
}
runtime := h.plugins.WebRuntime()
if runtime == nil {
return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, errors.New("canvas runtime is not configured")
}
path, err := runtime.IssueCapabilityPath(binding, webapp.RuntimeTokenTTL)
if err != nil {
return "", instances.Release{}, manifest.WebApp{}, webapp.CapabilityBinding{}, err
}
return path, release, app, binding, nil
}
func (h *canvasHTTPHandler) loadRuntimeRelease(c *gin.Context, canvas canvasservice.Canvas) (instances.Instance, instances.Release, *manifest.Manifest, error) {
store := h.plugins.Instances()
if store == nil {
return instances.Instance{}, instances.Release{}, nil, errors.New("canvas runtime is not configured")
}
instance, err := store.Get(c.Request.Context(), canvas.PluginInstanceID)
if err != nil {
return instances.Instance{}, instances.Release{}, nil, err
}
release, err := store.GetRelease(c.Request.Context(), instance.ActiveReleaseID)
if err != nil {
return instances.Instance{}, instances.Release{}, nil, err
}
appManifest, err := manifest.Parse(release.ManifestJSON)
if err != nil {
return instances.Instance{}, instances.Release{}, nil, err
}
return instance, release, appManifest, nil
}
func selectRuntimeWebApp(appManifest *manifest.Manifest, request canvasRuntimeRequest, scope string) (manifest.WebApp, string, error) {
placement := request.Placement
if placement == "" {
placement = appPlacement(manifest.WebApp{}, "", scope)
}
var app manifest.WebApp
for _, candidate := range appManifest.UI.WebApps {
if request.WebAppKey != "" && candidate.Key != request.WebAppKey {
continue
}
if placement != "" && !containsString(candidate.Placements, placement) {
continue
}
app = candidate
break
}
if app.Key == "" {
return manifest.WebApp{}, "", errCanvasWebAppNotDeclared
}
return app, appPlacement(app, request.Placement, scope), nil
}
func runtimeNetworkOrigins(app manifest.WebApp, scope string, grants []instances.Grant) []string {
declared, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins)
if err != nil || len(declared) == 0 {
return nil
}
origins := make([]string, 0, len(grants))
for _, origin := range declared {
for _, grant := range grants {
if grant.NetworkOrigin == origin && runtimeGrantScopeCovers(grant.ScopeCeiling, scope) && !containsString(origins, grant.NetworkOrigin) {
origins = append(origins, grant.NetworkOrigin)
}
}
}
return origins
}
func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) {
status := http.StatusInternalServerError
code := "canvas_error"
switch {
case errors.Is(err, canvasservice.ErrCanvasNotFound), errors.Is(err, instances.ErrNotFound):
status, code = http.StatusNotFound, "canvas_not_found"
case errors.Is(err, canvasservice.ErrTaskCanvasLimit), errors.Is(err, canvasservice.ErrWorkspaceCanvasLimit), errors.Is(err, instances.ErrTaskCanvasLimit), errors.Is(err, instances.ErrWorkspaceCanvasLimit):
status, code = http.StatusConflict, "canvas_limit_exceeded"
case errors.Is(err, instances.ErrWorkspaceStorageLimit), errors.Is(err, instances.ErrInstallationStorageLimit):
status, code = http.StatusConflict, "canvas_storage_limit_exceeded"
case errors.Is(err, instances.ErrInvalidRelease):
status, code = http.StatusConflict, "invalid_release"
case errors.Is(err, instances.ErrStaleCanvasPublish), errors.Is(err, canvasservice.ErrStaleCanvasPublish):
status, code = http.StatusConflict, "canvas_publish_stale"
case errors.Is(err, instances.ErrInvalidLifecycleState), errors.Is(err, canvasservice.ErrInvalidLifecycleState):
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
case errors.Is(err, canvasservice.ErrStalePromotionReview):
status, code = http.StatusConflict, "promotion_review_stale"
case errors.Is(err, canvasservice.ErrStaleWorkspaceDataReview):
status, code = http.StatusConflict, "workspace_data_review_stale"
case errors.Is(err, instances.ErrWorkspaceOwnerRequired):
status, code = http.StatusForbidden, "workspace_owner_required"
case errors.Is(err, canvasservice.ErrStaleCanvasEdit):
status, code = http.StatusConflict, "canvas_edit_stale"
case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState):
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
case errors.Is(err, errCanvasWebAppNotDeclared):
status, code = http.StatusBadRequest, "web_app_not_declared"
default:
}
writeCanvasError(c, status, code, nil)
}
func mapCanvasResponses(values []canvasservice.Canvas) []canvasHTTPResponse {
result := make([]canvasHTTPResponse, 0, len(values))
for _, value := range values {
result = append(result, canvasResponse(value))
}
return result
}
func canvasIncludeArchived(c *gin.Context) (bool, bool) {
value := c.Query("include_archived")
if value == "" {
return false, true
}
parsed, err := strconv.ParseBool(value)
if err != nil {
writeCanvasError(c, http.StatusBadRequest, "invalid_include_archived", nil)
return false, false
}
return parsed, true
}
func canvasRuntimeUser(c *gin.Context) string {
if identity, ok := authn.FromGin(c); ok && identity.UserID != "" {
return identity.UserID
}
return userstore.DefaultUserID
}
func appPlacement(app manifest.WebApp, requested, scope string) string {
if requested != "" {
return requested
}
wanted := manifest.WebAppPlacementWorkspace
if scope == instances.ScopeTask {
wanted = manifest.WebAppPlacementTask
}
if containsString(app.Placements, wanted) {
return wanted
}
if len(app.Placements) > 0 {
return app.Placements[0]
}
return wanted
}
func declaredPermissions(appManifest *manifest.Manifest) []string {
if appManifest == nil {
return nil
}
permissions := make([]string, 0, len(appManifest.Capabilities.APIRead)+len(appManifest.Capabilities.APIWrite)+len(appManifest.Capabilities.Events)+1)
for _, resource := range appManifest.Capabilities.APIRead {
permissions = appendUnique(permissions, "api_read:"+resource)
}
for _, resource := range appManifest.Capabilities.APIWrite {
permissions = appendUnique(permissions, "api_write:"+resource)
}
for _, event := range appManifest.Capabilities.Events {
permissions = appendUnique(permissions, "events:"+event)
}
if appManifest.Capabilities.State {
permissions = appendUnique(permissions, "state")
}
return permissions
}
func runtimeGrantedPermissions(appManifest *manifest.Manifest, scope string, grants []instances.Grant) []string {
declared := declaredPermissions(appManifest)
permissions := make([]string, 0, len(declared))
for _, permission := range declared {
parts := strings.SplitN(permission, ":", 2)
kind, resource := parts[0], ""
if len(parts) == 2 {
resource = parts[1]
}
for _, grant := range grants {
if grant.PermissionKind == kind && grant.Resource == resource && runtimeGrantScopeCovers(grant.ScopeCeiling, scope) {
permissions = append(permissions, permission)
break
}
}
}
return permissions
}
func runtimeGrantScopeCovers(ceiling, scope string) bool {
if ceiling == instances.ScopeInstance {
return true
}
return ceiling == scope
}
func appendUnique(values []string, value string) []string {
if value == "" || containsString(values, value) {
return values
}
return append(values, value)
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func canvasHostError(status string) string {
switch status {
case instances.ValidationPendingPermission:
return "permission_review_required"
case instances.ValidationInvalid:
return "invalid_release"
case instances.ValidationUnavailable:
return "runtime_unavailable"
default:
return "canvas_release_unavailable"
}
}
func writeCanvasJSON(c *gin.Context, status int, value interface{}) {
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.JSON(status, value)
}
func writeCanvasError(c *gin.Context, status int, code string, details map[string]interface{}) {
body := gin.H{"error": code, "error_code": code}
for key, value := range details {
body[key] = value
}
writeCanvasJSON(c, status, body)
}