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

@@ -4,7 +4,9 @@ import (
"database/sql"
"net/http"
"github.com/example/storage-appliance/internal/infra/osexec"
"github.com/example/storage-appliance/internal/service"
"github.com/example/storage-appliance/internal/service/storage"
)
// App contains injected dependencies for handlers.
@@ -15,4 +17,8 @@ type App struct {
JobRunner service.JobRunner
HTTPClient *http.Client
StorageSvc *storage.StorageService
ShareSvc service.SharesService
ISCSISvc service.ISCSIService
ObjectSvc service.ObjectService
Runner osexec.Runner
}

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)
}
}

View File

@@ -43,3 +43,81 @@ func TestCreatePoolHandler(t *testing.T) {
t.Fatalf("expected job_id in response")
}
}
func TestSharesNFSHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
req := httptest.NewRequest(http.MethodGet, "/shares/nfs", nil)
w := httptest.NewRecorder()
app.SharesNFSHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestCreateNFSHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
form := "name=data&path=tank/ds&options={}" // simple form body
req := httptest.NewRequest(http.MethodPost, "/shares/nfs/create", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Auth-User", "admin")
req.Header.Set("X-Auth-Role", "admin")
w := httptest.NewRecorder()
app.CreateNFSHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestNFSStatusHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
req := httptest.NewRequest(http.MethodGet, "/api/shares/nfs/status", nil)
w := httptest.NewRecorder()
app.NFSStatusHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestSharesSMBHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
req := httptest.NewRequest(http.MethodGet, "/shares/smb", nil)
w := httptest.NewRecorder()
app.SharesSMBHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestCreateSMBHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
form := "name=smb1&path=tank/ds&allowed_users=user1,user2&read_only=1"
req := httptest.NewRequest(http.MethodPost, "/shares/smb/create", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Auth-User", "admin")
req.Header.Set("X-Auth-Role", "admin")
w := httptest.NewRecorder()
app.CreateSMBHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestDeleteSMBHandler(t *testing.T) {
m := &mock.MockSharesService{}
app := &App{DB: &sql.DB{}, ShareSvc: m}
form := "id=smb-1"
req := httptest.NewRequest(http.MethodPost, "/shares/smb/delete", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Auth-User", "admin")
req.Header.Set("X-Auth-Role", "admin")
w := httptest.NewRecorder()
app.DeleteSMBHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String())
}
}

View File

@@ -2,9 +2,14 @@ package http
import (
"context"
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"strings"
"time"
"github.com/example/storage-appliance/internal/auth"
)
// ContextKey used to store values in context
@@ -12,6 +17,9 @@ type ContextKey string
const (
ContextKeyRequestID ContextKey = "request-id"
ContextKeyUser ContextKey = "user"
ContextKeyUserID ContextKey = "user.id"
ContextKeySession ContextKey = "session"
)
// RequestID middleware sets a request ID in headers and request context
@@ -30,49 +38,170 @@ func Logging(next http.Handler) http.Handler {
})
}
// Auth middleware placeholder to authenticate users
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic dev auth: read X-Auth-User; in real world, validate session/jwt
username := r.Header.Get("X-Auth-User")
if username == "" {
username = "anonymous"
}
// Role hint: header X-Auth-Role (admin/operator/viewer)
role := r.Header.Get("X-Auth-Role")
if role == "" {
if username == "admin" {
role = "admin"
} else {
role = "viewer"
// AuthMiddleware creates an auth middleware that uses the provided App
func AuthMiddleware(app *App) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for login and public routes
if strings.HasPrefix(r.URL.Path, "/login") || strings.HasPrefix(r.URL.Path, "/static") || r.URL.Path == "/healthz" || r.URL.Path == "/metrics" {
next.ServeHTTP(w, r)
return
}
}
ctx := context.WithValue(r.Context(), ContextKey("user"), username)
ctx = context.WithValue(ctx, ContextKey("user.role"), role)
next.ServeHTTP(w, r.WithContext(ctx))
})
// Get session token from cookie
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil {
// No session, redirect to login
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
// Validate session
sessionStore := auth.NewSessionStore(app.DB)
session, err := sessionStore.GetSession(r.Context(), cookie.Value)
if err != nil {
// Invalid session, redirect to login
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
// Get user
userStore := auth.NewUserStore(app.DB)
user, err := userStore.GetUserByID(r.Context(), session.UserID)
if err != nil {
http.Error(w, "user not found", http.StatusUnauthorized)
return
}
// Store user info in context
ctx := context.WithValue(r.Context(), ContextKeyUser, user.Username)
ctx = context.WithValue(ctx, ContextKeyUserID, user.ID)
ctx = context.WithValue(ctx, ContextKeySession, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CSRF middleware placeholder (reads X-CSRF-Token)
func CSRFMiddleware(next http.Handler) http.Handler {
// Auth is a legacy wrapper for backward compatibility
func Auth(next http.Handler) http.Handler {
// This will be replaced by AuthMiddleware in router
return next
}
// RequireAuth middleware ensures user is authenticated (alternative to Auth that doesn't redirect)
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: check and enforce CSRF tokens for mutating requests
userID := r.Context().Value(ContextKeyUserID)
if userID == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// RBAC middleware placeholder
func RBAC(permission string) func(http.Handler) http.Handler {
// CSRFMiddleware creates a CSRF middleware that uses the provided App
func CSRFMiddleware(app *App) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to read role from context and permit admin always
role := r.Context().Value(ContextKey("user.role"))
if role == "admin" {
// For safe methods, ensure CSRF token cookie exists
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
// Set CSRF token cookie if it doesn't exist
if cookie, err := r.Cookie("csrf_token"); err != nil || cookie.Value == "" {
token := generateCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: token,
Path: "/",
HttpOnly: false, // Needed for HTMX to read it
Secure: false,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
}
next.ServeHTTP(w, r)
return
}
// For now, only admin is permitted; add permission checks here
// Get CSRF token from header (HTMX compatible) or form
token := r.Header.Get("X-CSRF-Token")
if token == "" {
token = r.FormValue("csrf_token")
}
// Get expected token from cookie
expectedToken := getCSRFToken(r)
if token == "" || token != expectedToken {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// getCSRFToken retrieves or generates a CSRF token for the session
func getCSRFToken(r *http.Request) string {
// Try to get from cookie first
cookie, err := r.Cookie("csrf_token")
if err == nil && cookie.Value != "" {
return cookie.Value
}
// Generate new token (will be set in cookie by handler)
return generateCSRFToken()
}
func generateCSRFToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// RequirePermission creates a permission check middleware
func RequirePermission(app *App, permission string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(ContextKeyUserID)
if userID == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
rbacStore := auth.NewRBACStore(app.DB)
hasPermission, err := rbacStore.UserHasPermission(r.Context(), userID.(string), permission)
if err != nil {
log.Printf("permission check error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !hasPermission {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RBAC middleware (kept for backward compatibility)
func RBAC(permission string) func(http.Handler) http.Handler {
// This will be replaced by RequirePermission in router
return func(next http.Handler) http.Handler {
return next
}
}

View File

@@ -10,19 +10,76 @@ import (
func RegisterRoutes(r *chi.Mux, app *App) {
r.Use(Logging)
r.Use(RequestID)
r.Use(Auth)
r.Use(CSRFMiddleware(app))
r.Use(AuthMiddleware(app))
// Public routes
r.Get("/login", app.LoginHandler)
r.Post("/login", app.LoginHandler)
r.Post("/logout", app.LogoutHandler)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
r.Get("/metrics", app.MetricsHandler) // Prometheus metrics (public for scraping)
// Protected routes
r.Get("/", app.DashboardHandler)
r.Get("/dashboard", app.DashboardHandler)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
r.Get("/monitoring", app.MonitoringHandler)
r.Get("/hx/monitoring", app.HXMonitoringHandler)
r.Get("/hx/monitoring/group", app.HXMonitoringGroupHandler)
// API namespace
r.Route("/api", func(r chi.Router) {
r.Get("/pools", app.PoolsHandler)
r.With(RBAC("storage.pool.create")).Post("/pools", app.CreatePoolHandler) // create a pool -> creates a job
r.With(RequirePermission(app, "storage.pool.create")).Post("/pools", app.CreatePoolHandler) // create a pool -> creates a job
r.Get("/pools/{pool}/datasets", app.PoolDatasetsHandler)
r.With(RequirePermission(app, "storage.dataset.create")).Post("/datasets", app.CreateDatasetHandler)
r.With(RequirePermission(app, "storage.dataset.snapshot")).Post("/datasets/{dataset}/snapshot", app.SnapshotHandler)
r.With(RequirePermission(app, "storage.pool.scrub")).Post("/pools/{pool}/scrub", app.PoolScrubHandler)
r.Get("/jobs", app.JobsHandler)
r.Get("/shares/nfs/status", app.NFSStatusHandler)
})
r.Get("/storage", app.StorageHandler)
r.Get("/shares/nfs", app.SharesNFSHandler)
r.Get("/hx/shares/nfs", app.HXNFSHandler)
r.With(RequirePermission(app, "shares.nfs.create")).Post("/shares/nfs/create", app.CreateNFSHandler)
r.With(RequirePermission(app, "shares.nfs.delete")).Post("/shares/nfs/delete", app.DeleteNFSHandler)
r.Get("/shares/smb", app.SharesSMBHandler)
r.Get("/hx/shares/smb", app.HXSmbHandler)
r.With(RequirePermission(app, "shares.smb.create")).Post("/shares/smb/create", app.CreateSMBHandler)
r.With(RequirePermission(app, "shares.smb.delete")).Post("/shares/smb/delete", app.DeleteSMBHandler)
r.Get("/hx/pools", app.HXPoolsHandler)
r.Post("/storage/pool/create", app.StorageCreatePoolHandler)
r.With(RequirePermission(app, "storage.pool.create")).Post("/storage/pool/create", app.StorageCreatePoolHandler)
r.Get("/jobs/{id}", app.JobPartialHandler)
// iSCSI routes
r.Get("/iscsi", app.ISCSIHandler)
r.Get("/api/iscsi/hx_targets", app.HXISCSIHandler)
r.Get("/api/iscsi/hx_luns/{target}", app.HXISCLUNsHandler)
r.Get("/api/iscsi/target/{target}", app.ISCSITargetInfoHandler)
r.With(RequirePermission(app, "iscsi.target.create")).Post("/api/iscsi/create_target", app.CreateISCSITargetHandler)
r.With(RequirePermission(app, "iscsi.lun.create")).Post("/api/iscsi/create_lun", app.CreateISCSILUNHandler)
r.With(RequirePermission(app, "iscsi.lun.delete")).Post("/api/iscsi/delete_lun", app.DeleteISCSILUNHandler)
r.With(RequirePermission(app, "iscsi.lun.unmap")).Post("/api/iscsi/unmap_lun", app.UnmapISCSILUNHandler)
r.With(RequirePermission(app, "iscsi.portal.create")).Post("/api/iscsi/add_portal", app.AddISCSIPortalHandler)
r.With(RequirePermission(app, "iscsi.initiator.create")).Post("/api/iscsi/add_initiator", app.AddISCSIInitiatorHandler)
// Admin routes
r.Route("/admin", func(r chi.Router) {
r.Use(RequirePermission(app, "users.manage"))
r.Get("/users", app.UsersHandler)
r.Get("/hx/users", app.HXUsersHandler)
r.Post("/users/create", app.CreateUserHandler)
r.Post("/users/{id}/delete", app.DeleteUserHandler)
r.Post("/users/{id}/roles", app.UpdateUserRolesHandler)
r.Use(RequirePermission(app, "roles.manage"))
r.Get("/roles", app.RolesHandler)
r.Get("/hx/roles", app.HXRolesHandler)
r.Post("/roles/create", app.CreateRoleHandler)
r.Post("/roles/{id}/delete", app.DeleteRoleHandler)
r.Post("/roles/{id}/permissions", app.UpdateRolePermissionsHandler)
})
r.Get("/static/*", StaticHandler)
}