More webui layout, navigation, preparations of user/roles/groups/security
This commit is contained in:
@@ -36,7 +36,7 @@ func main() {
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: api.NewRouter(),
|
||||
Handler: api.NewRouter(configDir),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"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": {},
|
||||
"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
|
||||
}
|
||||
|
||||
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 loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return settings.Settings{Groups: []settings.Group{}}, nil
|
||||
}
|
||||
return settings.Settings{}, err
|
||||
}
|
||||
if payload.Groups == nil {
|
||||
payload.Groups = []settings.Group{}
|
||||
}
|
||||
return payload, 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")
|
||||
}
|
||||
|
||||
if len(group.Permissions) == 0 {
|
||||
return errors.New("group.permissions must be non-empty")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type groupsResponseTest struct {
|
||||
Groups []settings.Group `json:"groups"`
|
||||
}
|
||||
|
||||
type apiErrorResponseTest struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
var payload groupsResponseTest
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if payload.Groups == nil || len(payload.Groups) != 0 {
|
||||
t.Fatalf("expected empty groups list, got %#v", payload.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
"group": {
|
||||
"name": "Admins",
|
||||
"scope_kind": "node",
|
||||
"scope_name": "node-1",
|
||||
"permissions": ["nodes.nope"]
|
||||
}
|
||||
}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
var payload apiErrorResponseTest
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if payload.Error == "" {
|
||||
t.Fatal("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
"group": {
|
||||
"name": "Admins",
|
||||
"scope_kind": "node",
|
||||
"scope_name": "node-1",
|
||||
"permissions": ["nodes.read"]
|
||||
}
|
||||
}`)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
|
||||
}
|
||||
|
||||
updateBody := []byte(`{
|
||||
"group": {
|
||||
"name": "Admins",
|
||||
"scope_kind": "node",
|
||||
"scope_name": "node-2",
|
||||
"permissions": ["nodes.exec","nodes.update"]
|
||||
}
|
||||
}`)
|
||||
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody))
|
||||
updateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRec, updateReq)
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
|
||||
}
|
||||
|
||||
var getPayload groupsResponseTest
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(getPayload.Groups) != 1 {
|
||||
t.Fatalf("expected 1 group, got %d", len(getPayload.Groups))
|
||||
}
|
||||
if getPayload.Groups[0].Name != "Admins" {
|
||||
t.Fatalf("expected group name %q, got %q", "Admins", getPayload.Groups[0].Name)
|
||||
}
|
||||
if getPayload.Groups[0].ScopeName != "node-2" {
|
||||
t.Fatalf("expected scope_name %q, got %q", "node-2", getPayload.Groups[0].ScopeName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
"group": {
|
||||
"name": "Admins",
|
||||
"scope_kind": "node",
|
||||
"scope_name": "node-1",
|
||||
"permissions": ["nodes.read"]
|
||||
}
|
||||
}`)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
|
||||
}
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
|
||||
}
|
||||
|
||||
var getPayload groupsResponseTest
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(getPayload.Groups) != 0 {
|
||||
t.Fatalf("expected empty groups, got %#v", getPayload.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsDeleteMissingNameFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
router := NewRouter(configDir)
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
|
||||
if deleteRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, deleteRec.Code)
|
||||
}
|
||||
|
||||
var payload apiErrorResponseTest
|
||||
if err := json.NewDecoder(deleteRec.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Error == "" {
|
||||
t.Fatal("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewRouter().ServeHTTP(recorder, request)
|
||||
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
@@ -34,10 +35,11 @@ func TestHealthHandler(t *testing.T) {
|
||||
func TestStatusHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewRouter().ServeHTTP(recorder, request)
|
||||
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
@@ -58,10 +60,11 @@ func TestStatusHandler(t *testing.T) {
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
request := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewRouter().ServeHTTP(recorder, request)
|
||||
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code)
|
||||
|
||||
@@ -21,9 +21,12 @@ func withCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
func NewRouter(configDir string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", HealthHandler)
|
||||
mux.HandleFunc("GET /api/v1/status", StatusHandler)
|
||||
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
package settings
|
||||
|
||||
// GroupScopeKind identifies what a group scopes to.
|
||||
type GroupScopeKind string
|
||||
|
||||
const (
|
||||
GroupScopeNode GroupScopeKind = "node"
|
||||
GroupScopeGroup GroupScopeKind = "group"
|
||||
)
|
||||
|
||||
// Group configures a named permission set for a given scope target.
|
||||
//
|
||||
// Groups are persisted in settings.json and later used by authz middleware.
|
||||
type Group struct {
|
||||
Name string `json:"name"`
|
||||
ScopeKind GroupScopeKind `json:"scope_kind"`
|
||||
ScopeName string `json:"scope_name"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// Settings holds plain (non-secret) base/CLI configuration.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
|
||||
Reference in New Issue
Block a user