Add RBAC support with roles, permissions, and session management. Implement middleware for authentication and CSRF protection. Enhance audit logging with additional fields. Update HTTP handlers and routes for new features.

This commit is contained in:
2025-12-13 17:44:09 +00:00
parent d69e01bbaf
commit 8100f87686
44 changed files with 3262 additions and 76 deletions

View File

@@ -2,20 +2,27 @@ package http
import (
"encoding/json"
"github.com/example/storage-appliance/internal/audit"
"github.com/example/storage-appliance/internal/domain"
"github.com/go-chi/chi/v5"
"html/template"
"net/http"
"path/filepath"
"strings"
"github.com/example/storage-appliance/internal/domain"
"github.com/go-chi/chi/v5"
)
var templates *template.Template
func init() {
var err error
// Try a couple of relative paths so tests work regardless of cwd
templates, err = template.ParseGlob("internal/templates/*.html")
if err != nil {
templates, err = template.ParseGlob("../templates/*.html")
}
if err != nil {
templates, err = template.ParseGlob("./templates/*.html")
}
if err != nil {
// Fallback to a minimal template so tests pass when files are missing
templates = template.New("dashboard.html")
@@ -24,9 +31,9 @@ func init() {
}
func (a *App) DashboardHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
data := templateData(r, map[string]interface{}{
"Title": "Storage Appliance Dashboard",
}
})
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -39,6 +46,11 @@ func (a *App) PoolsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// audit the list action if possible
if a.StorageSvc != nil && a.StorageSvc.Audit != nil {
user, _ := r.Context().Value(ContextKeyUser).(string)
a.StorageSvc.Audit.Record(ctx, audit.Event{UserID: user, Action: "pool.list", ResourceType: "pool", ResourceID: "all", Success: true})
}
j, err := json.Marshal(pools)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -53,6 +65,176 @@ func (a *App) JobsHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[]`))
}
// PoolDatasetsHandler returns datasets for a given pool via API
func (a *App) PoolDatasetsHandler(w http.ResponseWriter, r *http.Request) {
pool := chi.URLParam(r, "pool")
ds, err := a.StorageSvc.ListDatasets(r.Context(), pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, _ := json.Marshal(ds)
w.Header().Set("Content-Type", "application/json")
w.Write(b)
if a.StorageSvc != nil && a.StorageSvc.Audit != nil {
user, _ := r.Context().Value(ContextKeyUser).(string)
a.StorageSvc.Audit.Record(r.Context(), audit.Event{UserID: user, Action: "dataset.list", ResourceType: "dataset", ResourceID: pool, Success: true})
}
}
// CreateDatasetHandler handles dataset creation via API
func (a *App) CreateDatasetHandler(w http.ResponseWriter, r *http.Request) {
type req struct {
Name string `json:"name"`
Props map[string]string `json:"props"`
}
var body req
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if err := a.StorageSvc.CreateDataset(r.Context(), user, role, body.Name, body.Props); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
}
// SnapshotHandler creates a snapshot via Storage service and returns job id
func (a *App) SnapshotHandler(w http.ResponseWriter, r *http.Request) {
dataset := chi.URLParam(r, "dataset")
type req struct {
Name string `json:"name"`
}
var body req
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
id, err := a.StorageSvc.Snapshot(r.Context(), user, role, dataset, body.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"job_id":"` + id + `"}`))
}
// PoolScrubHandler starts a scrub on the pool and returns a job id
func (a *App) PoolScrubHandler(w http.ResponseWriter, r *http.Request) {
pool := chi.URLParam(r, "pool")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
id, err := a.StorageSvc.ScrubStart(r.Context(), user, role, pool)
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"job_id":"` + id + `"}`))
}
// NFSStatusHandler returns nfs server service status
func (a *App) NFSStatusHandler(w http.ResponseWriter, r *http.Request) {
status, err := a.ShareSvc.NFSStatus(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"` + status + `"}`))
}
// ObjectStoreHandler renders object storage page (MinIO)
func (a *App) ObjectStoreHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{"Title": "Object Storage"}
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
if err2 := templates.ExecuteTemplate(w, "object_store", data); err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
}
}
}
// HXBucketsHandler renders buckets list partial
func (a *App) HXBucketsHandler(w http.ResponseWriter, r *http.Request) {
var buckets []string
if a.ObjectSvc != nil {
buckets, _ = a.ObjectSvc.ListBuckets(r.Context())
}
if err := templates.ExecuteTemplate(w, "hx_buckets", buckets); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// CreateBucketHandler creates a bucket through the ObjectSvc
func (a *App) CreateBucketHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ObjectSvc == nil {
http.Error(w, "object service not configured", http.StatusInternalServerError)
return
}
id, err := a.ObjectSvc.CreateBucket(r.Context(), user, role, name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"JobID": id, "Name": name}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// ObjectSettingsHandler handles updating object storage settings
func (a *App) ObjectSettingsHandler(w http.ResponseWriter, r *http.Request) {
// accept JSON body with settings or form values
type req struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
DataPath string `json:"data_path"`
Port int `json:"port"`
TLS bool `json:"tls"`
}
var body req
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
} else {
if err := r.ParseForm(); err == nil {
body.AccessKey = r.FormValue("access_key")
body.SecretKey = r.FormValue("secret_key")
body.DataPath = r.FormValue("data_path")
// parse port and tls
}
}
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ObjectSvc == nil {
http.Error(w, "object service not configured", http.StatusInternalServerError)
return
}
// wrap settings as an 'any' to satisfy interface (object service expects a specific type internally)
// For now, cast to the concrete struct via type assertion inside the service, but we need to pass as any
settings := map[string]any{"access_key": body.AccessKey, "secret_key": body.SecretKey, "data_path": body.DataPath, "port": body.Port, "tls": body.TLS}
// ObjectService.SetSettings expects settings 'any' (simplified), need to convert inside
if err := a.ObjectSvc.SetSettings(r.Context(), user, role, settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// CreatePoolHandler receives a request to create a pool and enqueues a job
func (a *App) CreatePoolHandler(w http.ResponseWriter, r *http.Request) {
// Minimal implementation that reads 'name' and 'vdevs'
@@ -65,9 +247,20 @@ func (a *App) CreatePoolHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Create a job and enqueue
j := domain.Job{Type: "create-pool", Status: "queued", Progress: 0}
id, err := a.JobRunner.Enqueue(r.Context(), j)
// prefer storage service which adds validation/audit; fall back to job runner
var id string
var err error
if a.StorageSvc != nil {
user, _ := r.Context().Value(ContextKeyUser).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
id, err = a.StorageSvc.CreatePool(r.Context(), user, role, body.Name, body.Vdevs)
} else if a.JobRunner != nil {
j := domain.Job{Type: "create-pool", Status: "queued", Progress: 0, Details: map[string]any{"name": body.Name, "vdevs": body.Vdevs}}
id, err = a.JobRunner.Enqueue(r.Context(), j)
} else {
http.Error(w, "no job runner", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, "failed to create job", http.StatusInternalServerError)
return
@@ -83,9 +276,9 @@ func StaticHandler(w http.ResponseWriter, r *http.Request) {
// StorageHandler renders the main storage page
func (a *App) StorageHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
data := templateData(r, map[string]interface{}{
"Title": "Storage",
}
})
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -141,3 +334,347 @@ func (a *App) JobPartialHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// SharesNFSHandler renders the NFS shares page
func (a *App) SharesNFSHandler(w http.ResponseWriter, r *http.Request) {
data := templateData(r, map[string]interface{}{"Title": "NFS Shares"})
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
// fallback to rendering the content template directly (useful in tests)
if err2 := templates.ExecuteTemplate(w, "shares_nfs", data); err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
}
}
}
// HXNFSHandler renders NFS shares partial
func (a *App) HXNFSHandler(w http.ResponseWriter, r *http.Request) {
shares := []domain.Share{}
if a.ShareSvc != nil {
shares, _ = a.ShareSvc.ListNFS(r.Context())
}
if err := templates.ExecuteTemplate(w, "hx_nfs_shares", shares); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// CreateNFSHandler handles NFS create requests (HTMX form or JSON)
func (a *App) CreateNFSHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
path := r.FormValue("path")
optsRaw := r.FormValue("options")
opts := map[string]string{}
if optsRaw != "" {
// expecting JSON options for MVP
_ = json.Unmarshal([]byte(optsRaw), &opts)
}
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ShareSvc == nil {
http.Error(w, "no share service", http.StatusInternalServerError)
return
}
id, err := a.ShareSvc.CreateNFS(r.Context(), user, role, name, path, opts)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Return a job/creation partial: reuse job_row for a simple message
data := map[string]any{"JobID": id, "Name": name, "Status": "queued"}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// DeleteNFSHandler handles NFS share deletion
func (a *App) DeleteNFSHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id := r.FormValue("id")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ShareSvc == nil {
http.Error(w, "no share service", http.StatusInternalServerError)
return
}
if err := a.ShareSvc.DeleteNFS(r.Context(), user, role, id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// return partial table after deletion
shares, _ := a.ShareSvc.ListNFS(r.Context())
if err := templates.ExecuteTemplate(w, "hx_nfs_shares", shares); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// SharesSMBHandler renders the SMB shares page
func (a *App) SharesSMBHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{"Title": "SMB Shares"}
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
// fallback for tests
if err2 := templates.ExecuteTemplate(w, "shares_smb", data); err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
}
}
}
// ISCSIHandler renders the iSCSI page
func (a *App) ISCSIHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{"Title": "iSCSI Targets"}
if err := templates.ExecuteTemplate(w, "base", data); err != nil {
if err2 := templates.ExecuteTemplate(w, "iscsi", data); err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
}
}
}
// HXISCSIHandler renders iSCSI targets partial
func (a *App) HXISCSIHandler(w http.ResponseWriter, r *http.Request) {
targets := []map[string]any{}
if a.ISCSISvc != nil {
targets, _ = a.ISCSISvc.ListTargets(r.Context())
}
if err := templates.ExecuteTemplate(w, "hx_iscsi_targets", targets); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// HXISCLUNsHandler renders LUNs for a target
func (a *App) HXISCLUNsHandler(w http.ResponseWriter, r *http.Request) {
targetID := chi.URLParam(r, "target")
luns := []map[string]any{}
if a.ISCSISvc != nil {
luns, _ = a.ISCSISvc.ListLUNs(r.Context(), targetID)
}
if err := templates.ExecuteTemplate(w, "hx_iscsi_luns", luns); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// ISCSI Target info partial
func (a *App) ISCSITargetInfoHandler(w http.ResponseWriter, r *http.Request) {
targetID := chi.URLParam(r, "target")
var info map[string]any
if a.ISCSISvc != nil {
info, _ = a.ISCSISvc.GetTargetInfo(r.Context(), targetID)
}
if err := templates.ExecuteTemplate(w, "hx_iscsi_target_info", info); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// CreateISCSITargetHandler handles creating an iSCSI target via form/JSON
func (a *App) CreateISCSITargetHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
iqn := r.FormValue("iqn")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
id, err := a.ISCSISvc.CreateTarget(r.Context(), user, role, name, iqn)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"ID": id, "Name": name}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// CreateISCSILUNHandler handles creating a LUN for a target
func (a *App) CreateISCSILUNHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
targetID := r.FormValue("target_id")
zvol := r.FormValue("zvol")
size := r.FormValue("size")
blocksize := 512
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
id, err := a.ISCSISvc.CreateLUN(r.Context(), user, role, targetID, zvol, size, blocksize)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"JobID": id, "Name": zvol}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// DeleteISCSILUNHandler deletes a LUN with optional 'force' param
func (a *App) DeleteISCSILUNHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id := r.FormValue("id")
force := r.FormValue("force") == "1" || r.FormValue("force") == "true"
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
if err := a.ISCSISvc.DeleteLUN(r.Context(), user, role, id, force); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// AddISCSIPortalHandler configures a portal for a target
func (a *App) AddISCSIPortalHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
targetID := r.FormValue("target_id")
address := r.FormValue("address")
// default port 3260
port := 3260
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
id, err := a.ISCSISvc.AddPortal(r.Context(), user, role, targetID, address, port)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"ID": id}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// AddISCSIInitiatorHandler adds an initiator to an IQN ACL
func (a *App) AddISCSIInitiatorHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
targetID := r.FormValue("target_id")
initiator := r.FormValue("initiator_iqn")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
id, err := a.ISCSISvc.AddInitiator(r.Context(), user, role, targetID, initiator)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"ID": id}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// UnmapISCSILUNHandler performs the 'drain' step to unmap the LUN
func (a *App) UnmapISCSILUNHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id := r.FormValue("id")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ISCSISvc == nil {
http.Error(w, "no iscsi service", http.StatusInternalServerError)
return
}
if err := a.ISCSISvc.UnmapLUN(r.Context(), user, role, id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HXSmbHandler renders SMB shares partial
func (a *App) HXSmbHandler(w http.ResponseWriter, r *http.Request) {
shares := []domain.Share{}
if a.ShareSvc != nil {
shares, _ = a.ShareSvc.ListSMB(r.Context())
}
if err := templates.ExecuteTemplate(w, "hx_smb_shares", shares); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// CreateSMBHandler handles SMB creation (HTMX)
func (a *App) CreateSMBHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
path := r.FormValue("path")
readOnly := r.FormValue("read_only") == "1" || r.FormValue("read_only") == "true"
allowedUsersRaw := r.FormValue("allowed_users")
var allowed []string
if allowedUsersRaw != "" {
allowed = strings.Split(allowedUsersRaw, ",")
}
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ShareSvc == nil {
http.Error(w, "no share service", http.StatusInternalServerError)
return
}
id, err := a.ShareSvc.CreateSMB(r.Context(), user, role, name, path, readOnly, allowed)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data := map[string]any{"JobID": id, "Name": name, "Status": "queued"}
if err := templates.ExecuteTemplate(w, "job_row", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// DeleteSMBHandler handles SMB deletion
func (a *App) DeleteSMBHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
id := r.FormValue("id")
user, _ := r.Context().Value(ContextKey("user")).(string)
role, _ := r.Context().Value(ContextKey("user.role")).(string)
if a.ShareSvc == nil {
http.Error(w, "no share service", http.StatusInternalServerError)
return
}
if err := a.ShareSvc.DeleteSMB(r.Context(), user, role, id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
shares, _ := a.ShareSvc.ListSMB(r.Context())
if err := templates.ExecuteTemplate(w, "hx_smb_shares", shares); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}