Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
203 lines
5.3 KiB
Go
203 lines
5.3 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": {},
|
|
"actions.create": {},
|
|
"actions.update": {},
|
|
"actions.delete": {},
|
|
"actions.execute": {},
|
|
"users.manage": {},
|
|
"secrets.manage": {},
|
|
"roles.manage": {},
|
|
"logs.read": {},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
action := "group_create"
|
|
if updated {
|
|
action = "group_update"
|
|
}
|
|
appendAuthAudit(
|
|
configDir,
|
|
action,
|
|
settings.AuthAuditOutcomeSuccess,
|
|
settings.AuthAuditCategoryGroup,
|
|
actorUsernameFromRequest(request),
|
|
payload.Group.Name,
|
|
"",
|
|
)
|
|
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
|
|
}
|
|
|
|
appendAuthAudit(
|
|
configDir,
|
|
"group_delete",
|
|
settings.AuthAuditOutcomeSuccess,
|
|
settings.AuthAuditCategoryGroup,
|
|
actorUsernameFromRequest(request),
|
|
name,
|
|
"",
|
|
)
|
|
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
|
|
}
|
|
|