Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 305 additions & 0 deletions internal/api/handlers/flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
package handlers

import (
"encoding/json"
"errors"
"net/http"
"strings"

"github.com/go-chi/chi/v5"
"github.com/go2engle/gantry/internal/api/middleware"
"github.com/go2engle/gantry/internal/auth"
"github.com/go2engle/gantry/internal/db"
"github.com/go2engle/gantry/internal/entity"
"github.com/go2engle/gantry/internal/events"
"github.com/go2engle/gantry/internal/plugins"
)

type flowSettingsResponse struct {
ShowInSidebar bool `json:"showInSidebar"`
EditorRole string `json:"editorRole"`
CanEdit bool `json:"canEdit"`
}

func flowEditorRole(config map[string]any) string {
role, _ := config["editorRole"].(string)
role = strings.TrimSpace(role)
if role == "" || !auth.IsValidRole(role) {
return "developer"
}
return role
}

func flowShowInSidebar(config map[string]any) bool {
show, ok := config["showInSidebar"].(bool)
if !ok {
return true
}
return show
}

func (h *Handlers) getFlowPlugin(r *http.Request) (*plugins.Plugin, error) {
plugin, err := h.DB.GetPlugin(r.Context(), "flow")
if err != nil {
return nil, err
}
if plugin == nil {
return nil, entity.ErrEntityNotFound
}
return plugin, nil
}

func (h *Handlers) getFlowSettings(r *http.Request) (*plugins.Plugin, flowSettingsResponse, error) {
plugin, err := h.getFlowPlugin(r)
if err != nil {
return nil, flowSettingsResponse{}, err
}

editorRole := flowEditorRole(plugin.Config)
effectiveRole := middleware.GetEffectiveRole(r.Context())
canEdit := auth.RoleLevel(effectiveRole) >= auth.RoleLevel(editorRole)

return plugin, flowSettingsResponse{
ShowInSidebar: flowShowInSidebar(plugin.Config),
EditorRole: editorRole,
CanEdit: canEdit,
}, nil
}

func (h *Handlers) ensureFlowWriteAccess(w http.ResponseWriter, r *http.Request) bool {
plugin, settings, err := h.getFlowSettings(r)
if err != nil {
if errors.Is(err, entity.ErrEntityNotFound) {
writeError(w, http.StatusNotFound, "flow plugin not installed")
return false
}
writeError(w, http.StatusInternalServerError, "failed to load flow plugin")
return false
}
if !plugin.Enabled {
writeError(w, http.StatusBadRequest, "flow plugin is not enabled")
return false
}
if !settings.CanEdit {
writeError(w, http.StatusForbidden, "insufficient permissions to edit flows")
return false
}
return true
}

// GetFlowSettings returns the non-sensitive Flow plugin settings needed by the UI.
func (h *Handlers) GetFlowSettings(w http.ResponseWriter, r *http.Request) {
plugin, settings, err := h.getFlowSettings(r)
if err != nil {
if errors.Is(err, entity.ErrEntityNotFound) {
writeError(w, http.StatusNotFound, "flow plugin not installed")
return
}
writeError(w, http.StatusInternalServerError, "failed to load flow settings")
return
}
if !plugin.Enabled {
writeError(w, http.StatusBadRequest, "flow plugin is not enabled")
return
}

writeJSON(w, http.StatusOK, settings)
}

// CreateFlowEntity handles POST /plugins/flow/entities.
func (h *Handlers) CreateFlowEntity(w http.ResponseWriter, r *http.Request) {
if !h.ensureFlowWriteAccess(w, r) {
return
}

var e entity.Entity
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if e.Kind != "" && e.Kind != "Flow" {
writeError(w, http.StatusBadRequest, "flow endpoint only accepts Flow entities")
return
}
e.Kind = "Flow"
e.SetDefaults()

claims := middleware.GetClaims(r.Context())
if claims != nil {
e.Metadata.CreatedBy = claims.Username
}

if err := h.Validator.Validate(&e); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}

if err := h.DB.CreateEntity(r.Context(), &e); err != nil {
if errors.Is(err, entity.ErrEntityAlreadyExists) {
writeError(w, http.StatusConflict, "entity already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create flow")
return
}

h.Events.Publish(events.Event{
Type: events.EntityCreated,
Data: map[string]any{
"kind": e.Kind,
"name": e.Metadata.Name,
"namespace": e.Metadata.Namespace,
},
})

userName := ""
userID := ""
if claims != nil {
userName = claims.Username
userID = claims.UserID
}
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
UserID: userID,
UserName: userName,
Action: "entity.created",
ResourceType: e.Kind,
ResourceName: e.Metadata.Name,
AfterState: marshalEntityState(&e),
Source: "api",
IPAddress: clientIP(r),
})

writeJSON(w, http.StatusCreated, e)
}

// UpdateFlowEntity handles PUT /plugins/flow/entities/{name}.
func (h *Handlers) UpdateFlowEntity(w http.ResponseWriter, r *http.Request) {
if !h.ensureFlowWriteAccess(w, r) {
return
}

name := chi.URLParam(r, "name")
var e entity.Entity
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}

if e.Kind != "" && e.Kind != "Flow" {
writeError(w, http.StatusBadRequest, "flow endpoint only accepts Flow entities")
return
}
e.Kind = "Flow"
e.Metadata.Name = name
e.SetDefaults()

if err := h.Validator.Validate(&e); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}

ns := e.Metadata.Namespace
if ns == "" {
ns = entity.DefaultNamespace
}

var beforeState string
if prev, err := h.DB.GetEntity(r.Context(), e.Kind, ns, e.Metadata.Name); err == nil {
beforeState = marshalEntityState(prev)
}

if err := h.DB.UpdateEntity(r.Context(), &e); err != nil {
if errors.Is(err, entity.ErrEntityNotFound) {
writeError(w, http.StatusNotFound, "entity not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update flow")
return
}

h.Events.Publish(events.Event{
Type: events.EntityUpdated,
Data: map[string]any{
"kind": e.Kind,
"name": e.Metadata.Name,
"namespace": e.Metadata.Namespace,
},
})

claims := middleware.GetClaims(r.Context())
userName := ""
userID := ""
if claims != nil {
userName = claims.Username
userID = claims.UserID
}
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
UserID: userID,
UserName: userName,
Action: "entity.updated",
ResourceType: e.Kind,
ResourceName: e.Metadata.Name,
BeforeState: beforeState,
AfterState: marshalEntityState(&e),
Source: "api",
IPAddress: clientIP(r),
})

writeJSON(w, http.StatusOK, e)
}

// DeleteFlowEntity handles DELETE /plugins/flow/entities/{name}.
func (h *Handlers) DeleteFlowEntity(w http.ResponseWriter, r *http.Request) {
if !h.ensureFlowWriteAccess(w, r) {
return
}

name := chi.URLParam(r, "name")
namespace := r.URL.Query().Get("namespace")
if namespace == "" {
namespace = entity.DefaultNamespace
}

var beforeState string
if prev, err := h.DB.GetEntity(r.Context(), "Flow", namespace, name); err == nil {
beforeState = marshalEntityState(prev)
}

if err := h.DB.DeleteEntity(r.Context(), "Flow", namespace, name); err != nil {
if errors.Is(err, entity.ErrEntityNotFound) {
writeError(w, http.StatusNotFound, "entity not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete flow")
return
}

h.Events.Publish(events.Event{
Type: events.EntityDeleted,
Data: map[string]any{
"kind": "Flow",
"name": name,
"namespace": namespace,
},
})

claims := middleware.GetClaims(r.Context())
userName := ""
userID := ""
if claims != nil {
userName = claims.Username
userID = claims.UserID
}
h.DB.CreateAuditEntry(r.Context(), &db.AuditEntry{
UserID: userID,
UserName: userName,
Action: "entity.deleted",
ResourceType: "Flow",
ResourceName: name,
BeforeState: beforeState,
Source: "api",
IPAddress: clientIP(r),
})

w.WriteHeader(http.StatusNoContent)
}
11 changes: 11 additions & 0 deletions internal/api/handlers/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/go-chi/chi/v5"
"github.com/go2engle/gantry/internal/auth"
"github.com/go2engle/gantry/internal/gitops"
"github.com/go2engle/gantry/internal/plugins"
argocd "github.com/go2engle/gantry/internal/plugins/argocd"
Expand Down Expand Up @@ -190,6 +191,16 @@ func (h *Handlers) UpdatePluginConfig(w http.ResponseWriter, r *http.Request) {
}

merged, _ := preserveSecretValues(existing.Config, config).(map[string]any)
if name == "flow" {
role, _ := merged["editorRole"].(string)
role = strings.TrimSpace(role)
if role == "" {
merged["editorRole"] = "developer"
} else if !auth.IsValidRole(role) {
writeError(w, http.StatusBadRequest, "invalid flow editor role")
return
}
}
Comment on lines +194 to +205
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist the normalized Flow editor role.

A value like " developer " is validated as developer but saved with whitespace, which can break downstream role checks. Store the trimmed value after validation.

🐛 Proposed fix
 	if name == "flow" {
 		role, _ := merged["editorRole"].(string)
 		role = strings.TrimSpace(role)
 		if role == "" {
 			merged["editorRole"] = "developer"
 		} else if !auth.IsValidRole(role) {
 			writeError(w, http.StatusBadRequest, "invalid flow editor role")
 			return
+		} else {
+			merged["editorRole"] = role
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/plugins.go` around lines 194 - 203, The Flow editor
role is being validated after trimming but the trimmed value isn't written back,
so whitespace like " developer " passes validation but is stored with spaces; in
the name == "flow" block update merged["editorRole"] to the trimmed role (after
computing role := strings.TrimSpace(...)), ensure you set merged["editorRole"] =
"developer" when role == "" and otherwise, after auth.IsValidRole(role) returns
true, persist merged["editorRole"] = role; use the merged map and
auth.IsValidRole references to locate the change.

if err := h.DB.UpdatePluginConfig(r.Context(), name, merged); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
Expand Down
4 changes: 4 additions & 0 deletions internal/api/handlers/topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ func (h *Handlers) GetTopologyData(w http.ResponseWriter, r *http.Request) {

// Second pass: collect all non-Environment entities and build edges.
for _, e := range all {
if e.Kind == "Flow" {
continue
}

nodeID := e.Kind + "/" + e.Metadata.Name
spec := e.Spec
if spec == nil {
Expand Down
5 changes: 5 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ func NewServer(cfg *config.Config, database *db.DB, authSvc *auth.Service, event
// Topology Explorer plugin endpoints.
protected.Get("/plugins/topology-explorer/data", h.GetTopologyData)
protected.Get("/plugins/topology-explorer/status", h.GetTopologyStatus)
// Flow plugin endpoints.
protected.Get("/plugins/flow/settings", h.GetFlowSettings)
protected.Post("/plugins/flow/entities", h.CreateFlowEntity)
protected.Put("/plugins/flow/entities/{name}", h.UpdateFlowEntity)
protected.Delete("/plugins/flow/entities/{name}", h.DeleteFlowEntity)
// Nexus Repository Manager plugin endpoints.
protected.Get("/plugins/nexus-repository-manager/repositories", h.GetNexusRepositories)
protected.Get("/plugins/nexus-repository-manager/components", h.GetNexusComponents)
Expand Down
1 change: 1 addition & 0 deletions internal/entity/kinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var BuiltinKinds = []KindDefinition{
{Name: "Team", Plural: "teams", Description: "Engineering team or group"},
{Name: "Environment", Plural: "environments", Description: "Deployment target or cloud account"},
{Name: "Documentation", Plural: "documentation", Description: "Link to external documentation or runbook"},
{Name: "Flow", Plural: "flows", Description: "Interactive system flow diagram backed by catalog entities"},
{Name: "Action", Plural: "actions", Description: "Self-service workflow definition"},
}

Expand Down
Loading