add authentication method
Some checks failed
CI / test-build (push) Failing after 2m1s

This commit is contained in:
2025-12-14 23:55:12 +07:00
parent ed96137bad
commit 54e76d9304
18 changed files with 2197 additions and 34 deletions

View File

@@ -5,8 +5,12 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/storage"
)
// pathParam is now in router_helpers.go
@@ -453,87 +457,496 @@ func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request)
// SMB Share Handlers
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
shares := []models.SMBShare{} // Stub
shares := a.smbStore.List()
writeJSON(w, http.StatusOK, shares)
}
func (a *App) handleCreateSMBShare(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Dataset string `json:"dataset"`
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
GuestOK bool `json:"guest_ok"`
ValidUsers []string `json:"valid_users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Name == "" || req.Dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and dataset are required"})
return
}
// Validate dataset exists
datasets, err := a.zfs.ListDatasets("")
if err != nil {
log.Printf("list datasets error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate dataset"})
return
}
datasetExists := false
for _, ds := range datasets {
if ds.Name == req.Dataset {
datasetExists = true
if req.Path == "" {
req.Path = ds.Mountpoint
}
break
}
}
if !datasetExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset not found"})
return
}
share, err := a.smbStore.Create(req.Name, req.Path, req.Dataset, req.Description, req.ReadOnly, req.GuestOK, req.ValidUsers)
if err != nil {
if err == storage.ErrSMBShareExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "share name already exists"})
return
}
log.Printf("create SMB share error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, share)
}
func (a *App) handleGetSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
share, err := a.smbStore.Get(id)
if err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, share)
}
func (a *App) handleUpdateSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
var req struct {
Description string `json:"description"`
ReadOnly bool `json:"read_only"`
GuestOK bool `json:"guest_ok"`
ValidUsers []string `json:"valid_users"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.smbStore.Update(id, req.Description, req.ReadOnly, req.GuestOK, req.Enabled, req.ValidUsers); err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update SMB share error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
share, _ := a.smbStore.Get(id)
writeJSON(w, http.StatusOK, share)
}
func (a *App) handleDeleteSMBShare(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/shares/smb/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "share id required"})
return
}
if err := a.smbStore.Delete(id); err != nil {
if err == storage.ErrSMBShareNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete SMB share error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "share deleted", "id": id})
}
// NFS Export Handlers
func (a *App) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
exports := []models.NFSExport{} // Stub
exports := a.nfsStore.List()
writeJSON(w, http.StatusOK, exports)
}
func (a *App) handleCreateNFSExport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
var req struct {
Path string `json:"path"`
Dataset string `json:"dataset"`
Clients []string `json:"clients"`
ReadOnly bool `json:"read_only"`
RootSquash bool `json:"root_squash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Dataset == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset is required"})
return
}
// Validate dataset exists
datasets, err := a.zfs.ListDatasets("")
if err != nil {
log.Printf("list datasets error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate dataset"})
return
}
datasetExists := false
for _, ds := range datasets {
if ds.Name == req.Dataset {
datasetExists = true
if req.Path == "" {
req.Path = ds.Mountpoint
}
break
}
}
if !datasetExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "dataset not found"})
return
}
// Default clients to "*" (all) if not specified
if req.Clients == nil || len(req.Clients) == 0 {
req.Clients = []string{"*"}
}
export, err := a.nfsStore.Create(req.Path, req.Dataset, req.Clients, req.ReadOnly, req.RootSquash)
if err != nil {
if err == storage.ErrNFSExportExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "export for this path already exists"})
return
}
log.Printf("create NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, export)
}
func (a *App) handleGetNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
export, err := a.nfsStore.Get(id)
if err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, export)
}
func (a *App) handleUpdateNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
var req struct {
Clients []string `json:"clients"`
ReadOnly bool `json:"read_only"`
RootSquash bool `json:"root_squash"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.nfsStore.Update(id, req.Clients, req.ReadOnly, req.RootSquash, req.Enabled); err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
export, _ := a.nfsStore.Get(id)
writeJSON(w, http.StatusOK, export)
}
func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/exports/nfs/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "export id required"})
return
}
if err := a.nfsStore.Delete(id); err != nil {
if err == storage.ErrNFSExportNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete NFS export error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "export deleted", "id": id})
}
// iSCSI Handlers
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
targets := []models.ISCSITarget{} // Stub
targets := a.iscsiStore.List()
writeJSON(w, http.StatusOK, targets)
}
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
var req struct {
IQN string `json:"iqn"`
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.IQN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "iqn is required"})
return
}
// Basic IQN format validation (iqn.yyyy-mm.reversed.domain:identifier)
if !strings.HasPrefix(req.IQN, "iqn.") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid IQN format (must start with 'iqn.')"})
return
}
target, err := a.iscsiStore.Create(req.IQN, req.Initiators)
if err != nil {
if err == storage.ErrISCSITargetExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "target with this IQN already exists"})
return
}
log.Printf("create iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, target)
}
func (a *App) handleGetISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
target, err := a.iscsiStore.Get(id)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, target)
}
func (a *App) handleUpdateISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
var req struct {
Initiators []string `json:"initiators"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.iscsiStore.Update(id, req.Initiators, req.Enabled); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("update iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
target, _ := a.iscsiStore.Get(id)
writeJSON(w, http.StatusOK, target)
}
func (a *App) handleDeleteISCSITarget(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
if err := a.iscsiStore.Delete(id); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
log.Printf("delete iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "target deleted", "id": id})
}
func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
// Extract target ID from path like /api/v1/iscsi/targets/{id}/luns
path := strings.TrimPrefix(r.URL.Path, "/api/v1/iscsi/targets/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
id := parts[0]
var req struct {
ZVOL string `json:"zvol"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.ZVOL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
return
}
// Validate ZVOL exists
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
log.Printf("list zvols error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
return
}
var zvolSize uint64
zvolExists := false
for _, zvol := range zvols {
if zvol.Name == req.ZVOL {
zvolExists = true
zvolSize = zvol.Size
break
}
}
if !zvolExists {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
return
}
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
return
}
if err == storage.ErrLUNExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "zvol already mapped to this target"})
return
}
log.Printf("add LUN error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, lun)
}
func (a *App) handleRemoveLUN(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/iscsi/targets/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
// Extract target ID from path like /api/v1/iscsi/targets/{id}/luns/remove
path := strings.TrimPrefix(r.URL.Path, "/api/v1/iscsi/targets/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target id required"})
return
}
id := parts[0]
var req struct {
LUNID int `json:"lun_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.iscsiStore.RemoveLUN(id, req.LUNID); err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
return
}
if err == storage.ErrLUNNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "LUN not found"})
return
}
log.Printf("remove LUN error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "LUN removed", "target_id": id, "lun_id": strconv.Itoa(req.LUNID)})
}
// Job Handlers
@@ -575,42 +988,201 @@ func (a *App) handleCancelJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "job cancelled", "id": id})
}
// Auth Handlers (stubs)
// Auth Handlers
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Username == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password are required"})
return
}
user, err := a.userStore.Authenticate(req.Username, req.Password)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
token, err := a.authService.GenerateToken(user.ID, string(user.Role))
if err != nil {
log.Printf("generate token error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
"expires_in": 86400, // 24 hours in seconds
})
}
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
// JWT is stateless, so logout is just client-side token removal
// In a stateful system, you'd invalidate the token here
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
// User Handlers
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
users := []models.User{} // Stub
// Only administrators can list users
users := a.userStore.List()
writeJSON(w, http.StatusOK, users)
}
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role models.Role `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Username == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password are required"})
return
}
if req.Role == "" {
req.Role = models.RoleViewer // Default role
}
// Validate role
if req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
return
}
user, err := a.userStore.Create(req.Username, req.Email, req.Password, req.Role)
if err != nil {
if err == auth.ErrUserExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"})
return
}
log.Printf("create user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, user)
}
func (a *App) handleGetUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
user, err := a.userStore.GetByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, user)
}
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
var req struct {
Email string `json:"email"`
Role models.Role `json:"role"`
Active bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
// Validate role if provided
if req.Role != "" && req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
return
}
// Use existing role if not provided
if req.Role == "" {
existingUser, err := a.userStore.GetByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
req.Role = existingUser.Role
}
if err := a.userStore.Update(id, req.Email, req.Role, req.Active); err != nil {
log.Printf("update user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
user, _ := a.userStore.GetByID(id)
writeJSON(w, http.StatusOK, user)
}
func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := pathParam(r, "/api/v1/users/")
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented", "id": id})
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user id required"})
return
}
// Prevent deleting yourself
currentUser, ok := getUserFromContext(r)
if ok && currentUser.ID == id {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete your own account"})
return
}
if err := a.userStore.Delete(id); err != nil {
log.Printf("delete user error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "user deleted", "id": id})
}
// Audit Log Handlers
func (a *App) handleListAuditLogs(w http.ResponseWriter, r *http.Request) {
logs := []models.AuditLog{} // Stub
// Get query parameters
actor := r.URL.Query().Get("actor")
action := r.URL.Query().Get("action")
resource := r.URL.Query().Get("resource")
limitStr := r.URL.Query().Get("limit")
limit := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
// Default limit to 100 if not specified
if limit == 0 {
limit = 100
}
logs := a.auditStore.List(actor, action, resource, limit)
writeJSON(w, http.StatusOK, logs)
}

View File

@@ -4,11 +4,16 @@ import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"time"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/audit"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/db"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/job"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/snapshot"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/storage"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/zfs"
)
@@ -16,6 +21,7 @@ type Config struct {
Addr string
TemplatesDir string
StaticDir string
DatabasePath string // Path to SQLite database (empty = in-memory mode)
}
type App struct {
@@ -26,6 +32,13 @@ type App struct {
snapshotPolicy *snapshot.PolicyStore
jobManager *job.Manager
scheduler *snapshot.Scheduler
authService *auth.Service
userStore *auth.UserStore
auditStore *audit.Store
smbStore *storage.SMBStore
nfsStore *storage.NFSStore
iscsiStore *storage.ISCSIStore
database *db.DB // Optional database connection
}
func New(cfg Config) (*App, error) {
@@ -46,6 +59,29 @@ func New(cfg Config) (*App, error) {
jobMgr := job.NewManager()
scheduler := snapshot.NewScheduler(policyStore, zfsService, jobMgr)
// Initialize database (optional)
var database *db.DB
if cfg.DatabasePath != "" {
dbConn, err := db.New(cfg.DatabasePath)
if err != nil {
return nil, fmt.Errorf("init database: %w", err)
}
database = dbConn
}
// Initialize auth
jwtSecret := os.Getenv("ATLAS_JWT_SECRET")
authService := auth.New(jwtSecret)
userStore := auth.NewUserStore(authService)
// Initialize audit logging (keep last 10000 logs)
auditStore := audit.NewStore(10000)
// Initialize storage services
smbStore := storage.NewSMBStore()
nfsStore := storage.NewNFSStore()
iscsiStore := storage.NewISCSIStore()
a := &App{
cfg: cfg,
tmpl: tmpl,
@@ -54,6 +90,13 @@ func New(cfg Config) (*App, error) {
snapshotPolicy: policyStore,
jobManager: jobMgr,
scheduler: scheduler,
authService: authService,
userStore: userStore,
auditStore: auditStore,
smbStore: smbStore,
nfsStore: nfsStore,
iscsiStore: iscsiStore,
database: database,
}
// Start snapshot scheduler (runs every 15 minutes)
@@ -64,8 +107,8 @@ func New(cfg Config) (*App, error) {
}
func (a *App) Router() http.Handler {
// Wrap the mux with basic middleware chain
return requestID(logging(a.mux))
// Wrap the mux with middleware chain: requestID -> logging -> audit -> auth
return requestID(logging(a.auditMiddleware(a.authMiddleware(a.mux))))
}
// StopScheduler stops the snapshot scheduler (for graceful shutdown)
@@ -73,6 +116,10 @@ func (a *App) StopScheduler() {
if a.scheduler != nil {
a.scheduler.Stop()
}
// Close database connection if present
if a.database != nil {
a.database.Close()
}
}
// routes() is now in routes.go

View File

@@ -0,0 +1,132 @@
package httpapp
import (
"fmt"
"net/http"
"strings"
)
// auditMiddleware logs all mutating operations
func (a *App) auditMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only log mutating operations (POST, PUT, DELETE, PATCH)
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// Skip audit for public endpoints
if a.isPublicEndpoint(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Get user from context (if authenticated)
actor := "system"
user, ok := getUserFromContext(r)
if ok {
actor = user.ID
}
// Extract action from method and path
action := extractAction(r.Method, r.URL.Path)
resource := extractResource(r.URL.Path)
// Get client info
ip := getClientIP(r)
userAgent := r.UserAgent()
// Create response writer wrapper to capture status code
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
// Execute the handler
next.ServeHTTP(rw, r)
// Log the operation
result := "success"
message := ""
if rw.statusCode >= 400 {
result = "failure"
message = http.StatusText(rw.statusCode)
}
a.auditStore.Log(actor, action, resource, result, message, ip, userAgent)
})
}
// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// extractAction extracts action name from HTTP method and path
func extractAction(method, path string) string {
// Remove /api/v1 prefix
path = strings.TrimPrefix(path, "/api/v1")
path = strings.Trim(path, "/")
parts := strings.Split(path, "/")
resource := parts[0]
// Map HTTP methods to actions
actionMap := map[string]string{
http.MethodPost: "create",
http.MethodPut: "update",
http.MethodPatch: "update",
http.MethodDelete: "delete",
}
action := actionMap[method]
if action == "" {
action = strings.ToLower(method)
}
return fmt.Sprintf("%s.%s", resource, action)
}
// extractResource extracts resource identifier from path
func extractResource(path string) string {
// Remove /api/v1 prefix
path = strings.TrimPrefix(path, "/api/v1")
path = strings.Trim(path, "/")
parts := strings.Split(path, "/")
if len(parts) == 0 {
return "unknown"
}
resource := parts[0]
if len(parts) > 1 {
// Include resource ID if present
resource = fmt.Sprintf("%s/%s", resource, parts[1])
}
return resource
}
// getClientIP extracts client IP from request
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header (for proxies)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fallback to RemoteAddr
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx != -1 {
ip = ip[:idx]
}
return ip
}

View File

@@ -0,0 +1,134 @@
package httpapp
import (
"context"
"net/http"
"strings"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/auth"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
)
const (
userCtxKey ctxKey = "user"
roleCtxKey ctxKey = "role"
)
// authMiddleware validates JWT tokens and extracts user info
func (a *App) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for public endpoints
if a.isPublicEndpoint(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing authorization header"})
return
}
// Parse "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid authorization header format"})
return
}
token := parts[1]
claims, err := a.authService.ValidateToken(token)
if err != nil {
if err == auth.ErrExpiredToken {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token expired"})
} else {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
}
return
}
// Get user from store
user, err := a.userStore.GetByID(claims.UserID)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "user not found"})
return
}
if !user.Active {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "user account is disabled"})
return
}
// Add user info to context
ctx := context.WithValue(r.Context(), userCtxKey, user)
ctx = context.WithValue(ctx, roleCtxKey, user.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireRole middleware checks if user has required role
func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(roleCtxKey).(models.Role)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
// Check if user role is in allowed roles
allowed := false
for _, allowedRole := range allowedRoles {
if role == allowedRole {
allowed = true
break
}
}
if !allowed {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient permissions"})
return
}
next.ServeHTTP(w, r)
})
}
}
// isPublicEndpoint checks if an endpoint is public (no auth required)
func (a *App) isPublicEndpoint(path string) bool {
publicPaths := []string{
"/healthz",
"/metrics",
"/api/v1/auth/login",
"/api/v1/auth/logout",
"/", // Dashboard (can be made protected later)
}
for _, publicPath := range publicPaths {
if path == publicPath || strings.HasPrefix(path, publicPath+"/") {
return true
}
}
// Static files are public
if strings.HasPrefix(path, "/static/") {
return true
}
return false
}
// getUserFromContext extracts user from request context
func getUserFromContext(r *http.Request) (*models.User, bool) {
user, ok := r.Context().Value(userCtxKey).(*models.User)
return user, ok
}
// getRoleFromContext extracts role from request context
func getRoleFromContext(r *http.Request) (models.Role, bool) {
role, ok := r.Context().Value(roleCtxKey).(models.Role)
return role, ok
}

View File

@@ -144,9 +144,27 @@ func (a *App) handleNFSExportOps(w http.ResponseWriter, r *http.Request) {
// handleISCSITargetOps routes iSCSI target operations by method
func (a *App) handleISCSITargetOps(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/luns") {
if r.Method == http.MethodPost {
a.handleAddLUN(w, r)
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if strings.HasSuffix(r.URL.Path, "/luns/remove") {
if r.Method == http.MethodPost {
a.handleRemoveLUN(w, r)
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
methodHandler(
func(w http.ResponseWriter, r *http.Request) { a.handleGetISCSITarget(w, r) },
func(w http.ResponseWriter, r *http.Request) { a.handleCreateISCSITarget(w, r) },
nil,
func(w http.ResponseWriter, r *http.Request) { a.handleUpdateISCSITarget(w, r) },
func(w http.ResponseWriter, r *http.Request) { a.handleDeleteISCSITarget(w, r) },
nil,

View File

@@ -1,6 +1,10 @@
package httpapp
import "net/http"
import (
"net/http"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
)
func (a *App) routes() {
// Static files
@@ -85,7 +89,7 @@ func (a *App) routes() {
))
a.mux.HandleFunc("/api/v1/jobs/", a.handleJobOps)
// Authentication & Authorization
// Authentication & Authorization (public endpoints)
a.mux.HandleFunc("/api/v1/auth/login", methodHandler(
nil,
func(w http.ResponseWriter, r *http.Request) { a.handleLogin(w, r) },
@@ -96,12 +100,17 @@ func (a *App) routes() {
func(w http.ResponseWriter, r *http.Request) { a.handleLogout(w, r) },
nil, nil, nil,
))
// User Management (requires authentication, admin-only for create/update/delete)
a.mux.HandleFunc("/api/v1/users", methodHandler(
func(w http.ResponseWriter, r *http.Request) { a.handleListUsers(w, r) },
func(w http.ResponseWriter, r *http.Request) { a.handleCreateUser(w, r) },
func(w http.ResponseWriter, r *http.Request) {
adminRole := models.RoleAdministrator
a.requireRole(adminRole)(http.HandlerFunc(a.handleCreateUser)).ServeHTTP(w, r)
},
nil, nil, nil,
))
a.mux.HandleFunc("/api/v1/users/", a.handleUserOps)
a.mux.HandleFunc("/api/v1/users/", a.handleUserOpsWithAuth)
// Audit Logs
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)

View File

@@ -0,0 +1,78 @@
package httpapp
import (
"encoding/json"
"log"
"net/http"
"strings"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
)
// handleUserOpsWithAuth routes user operations with auth
func (a *App) handleUserOpsWithAuth(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/password") {
// Password change endpoint (requires auth, can change own password)
if r.Method == http.MethodPut {
a.handleChangePassword(w, r)
return
}
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Regular user operations (admin-only)
methodHandler(
func(w http.ResponseWriter, r *http.Request) {
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleGetUser)).ServeHTTP(w, r)
},
nil,
func(w http.ResponseWriter, r *http.Request) {
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleUpdateUser)).ServeHTTP(w, r)
},
func(w http.ResponseWriter, r *http.Request) {
a.requireRole(models.RoleAdministrator)(http.HandlerFunc(a.handleDeleteUser)).ServeHTTP(w, r)
},
nil,
)(w, r)
}
// handleChangePassword allows users to change their own password
func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
user, ok := getUserFromContext(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.NewPassword == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password is required"})
return
}
// Verify old password
_, err := a.userStore.Authenticate(user.Username, req.OldPassword)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid current password"})
return
}
// Update password
if err := a.userStore.UpdatePassword(user.ID, req.NewPassword); err != nil {
log.Printf("update password error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "password updated"})
}