Files
ClusterCanvas/service/internal/api/groups_handlers.go
T
Squid 77be394407 Add nodes.delete with reauth, activity logs, and Administrators editing.
Allow deleting managed hosts with mandatory inline re-auth, record node mutations in an audit log with a sidebar Logs view, and let Administrators group permissions be edited from the Groups tab.
2026-07-18 16:54:13 +02:00

176 lines
4.8 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type groupsResponse struct {
Groups []settings.Group `json:"groups"`
}
type apiErrorResponse struct {
Error string `json:"error"`
}
type upsertGroupRequest struct {
Group settings.Group `json:"group"`
}
var allowedPermissions = map[string]struct{}{
"nodes.read": {},
"nodes.exec": {},
"nodes.update": {},
"nodes.delete": {},
"jobs.read": {},
"jobs.run": {},
"users.manage": {},
"secrets.manage": {},
"roles.manage": {},
}
func groupsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
func groupsUpsertHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeGroupsWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload upsertGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateGroup(payload.Group); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
updated := false
for index := range settingsPayload.Groups {
if settingsPayload.Groups[index].Name == payload.Group.Name {
settingsPayload.Groups[index] = payload.Group
updated = true
break
}
}
if !updated {
settingsPayload.Groups = append(settingsPayload.Groups, payload.Group)
}
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
func groupsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeGroupsWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
name := strings.TrimSpace(request.URL.Query().Get("name"))
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing group name"})
return
}
if name == settings.AdministratorsGroupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "Administrators group cannot be deleted",
})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
remaining := make([]settings.Group, 0, len(settingsPayload.Groups))
for _, group := range settingsPayload.Groups {
if group.Name != name {
remaining = append(remaining, group)
}
}
if len(remaining) == len(settingsPayload.Groups) {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "group not found"})
return
}
settingsPayload.Groups = remaining
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
func authorizeGroupsWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateGroup(group settings.Group) error {
if strings.TrimSpace(group.Name) == "" {
return errors.New("group.name is required")
}
switch group.ScopeKind {
case settings.GroupScopeNode, settings.GroupScopeGroup:
// ok
default:
return errors.New("group.scope_kind must be \"node\" or \"group\"")
}
if strings.TrimSpace(group.ScopeName) == "" {
return errors.New("group.scope_name is required")
}
// Empty permissions are allowed so a newly scoped node group can be
// created first and roles assigned later in Configuration → Groups.
for _, permission := range group.Permissions {
if strings.TrimSpace(permission) == "" {
return errors.New("permission strings must be non-empty")
}
if _, ok := allowedPermissions[permission]; !ok {
return errors.New("permission is not in the built-in permission catalog")
}
}
return nil
}