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

@@ -12,9 +12,45 @@ type DiskService interface {
type ZFSService interface {
ListPools(ctx context.Context) ([]domain.Pool, error)
CreatePool(ctx context.Context, name string, vdevs []string) (string, error)
// CreatePool is a higher level operation handled by StorageService with jobs
// CreatePool(ctx context.Context, name string, vdevs []string) (string, error)
GetPoolStatus(ctx context.Context, pool string) (domain.PoolHealth, error)
ListDatasets(ctx context.Context, pool string) ([]domain.Dataset, error)
CreateDataset(ctx context.Context, name string, props map[string]string) error
Snapshot(ctx context.Context, dataset, snapName string) error
ScrubStart(ctx context.Context, pool string) error
ScrubStatus(ctx context.Context, pool string) (string, error)
}
type JobRunner interface {
Enqueue(ctx context.Context, j domain.Job) (string, error)
}
type SharesService interface {
ListNFS(ctx context.Context) ([]domain.Share, error)
CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error)
DeleteNFS(ctx context.Context, user, role, id string) error
NFSStatus(ctx context.Context) (string, error)
ListSMB(ctx context.Context) ([]domain.Share, error)
CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error)
DeleteSMB(ctx context.Context, user, role, id string) error
}
type ObjectService interface {
SetSettings(ctx context.Context, user, role string, s map[string]any) error
GetSettings(ctx context.Context) (map[string]any, error)
ListBuckets(ctx context.Context) ([]string, error)
CreateBucket(ctx context.Context, user, role, name string) (string, error)
}
type ISCSIService interface {
ListTargets(ctx context.Context) ([]map[string]any, error)
CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error)
CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error)
DeleteLUN(ctx context.Context, user, role, id string, force bool) error
UnmapLUN(ctx context.Context, user, role, id string) error
AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error)
AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error)
ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error)
GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error)
}

View File

@@ -0,0 +1,228 @@
package iscsi
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/example/storage-appliance/internal/audit"
"github.com/example/storage-appliance/internal/infra/iscsi"
"github.com/example/storage-appliance/internal/infra/zfs"
)
var (
ErrForbidden = errors.New("forbidden")
)
type ISCSIService struct {
DB *sql.DB
ZFS *zfs.Adapter
ISCSI *iscsi.Adapter
Audit audit.AuditLogger
}
func NewISCSIService(db *sql.DB, z *zfs.Adapter, i *iscsi.Adapter, a audit.AuditLogger) *ISCSIService {
return &ISCSIService{DB: db, ZFS: z, ISCSI: i, Audit: a}
}
func (s *ISCSIService) ListTargets(ctx context.Context) ([]map[string]any, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT id, iqn, name, created_at FROM iscsi_targets`)
if err != nil { return nil, err }
defer rows.Close()
res := []map[string]any{}
for rows.Next() {
var id, iqn, name string
var created time.Time
if err := rows.Scan(&id, &iqn, &name, &created); err != nil { return nil, err }
res = append(res, map[string]any{"id": id, "iqn": iqn, "name": name, "created_at": created})
}
return res, nil
}
func (s *ISCSIService) CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error) {
if role != "admin" { return "", ErrForbidden }
if iqn == "" || !strings.HasPrefix(iqn, "iqn.") { return "", errors.New("invalid IQN") }
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_targets (id, iqn, name) VALUES (?, ?, ?)`, id, iqn, name); err != nil {
return "", err
}
if s.ISCSI != nil {
if err := s.ISCSI.CreateTarget(ctx, iqn); err != nil {
return "", err
}
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
}
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.target.create", ResourceType: "iscsi_target", ResourceID: id, Success: true}) }
return id, nil
}
// CreateLUN creates a zvol and maps it as LUN for the IQN. lunName is the zvol path e.g. pool/dataset/vol
func (s *ISCSIService) CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error) {
if role != "admin" && role != "operator" { return "", ErrForbidden }
// fetch target IQN
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil {
return "", err
}
// build zvol name
zvol := lunName // expect fully qualified dataset, e.g., pool/iscsi/target/lun0
// create zvol via zfs adapter
props := map[string]string{}
if blocksize > 0 {
// convert bytes to K unit if divisible
// For simplicity, just set volblocksize as "8K" or "512"; attempt simple conversion
props["volblocksize"] = fmt.Sprintf("%d", blocksize)
}
if s.ZFS != nil {
if _, err := s.ZFS.ListDatasets(ctx, ""); err == nil { // no-op to check connectivity
}
if err := s.ZFS.CreateZVol(ctx, zvol, size, props); err != nil {
return "", err
}
}
// backstore name and device path
bsName := "bs-" + uuid.New().String()
devpath := "/dev/zvol/" + zvol
if s.ISCSI != nil {
if err := s.ISCSI.CreateBackstore(ctx, bsName, devpath); err != nil {
return "", err
}
}
// determine LUN ID as next available for target
var maxLun sql.NullInt64
if err := s.DB.QueryRowContext(ctx, `SELECT MAX(lun_id) FROM iscsi_luns WHERE target_id = ?`, targetID).Scan(&maxLun); err != nil && err != sql.ErrNoRows { return "", err }
nextLun := 0
if maxLun.Valid { nextLun = int(maxLun.Int64) + 1 }
if s.ISCSI != nil {
if err := s.ISCSI.CreateLUN(ctx, iqn, bsName, nextLun); err != nil {
return "", err
}
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
}
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_luns (id, target_id, lun_id, zvol, size, blocksize, mapped) VALUES (?, ?, ?, ?, ?, ?, 1)`, id, targetID, nextLun, zvol, sizeToInt(size), blocksize); err != nil {
return "", err
}
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.create", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
return id, nil
}
func sizeToInt(s string) int {
// naive conversion: strip trailing G/M/K
// This function can be improved; for now return 0
return 0
}
func (s *ISCSIService) ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT id, lun_id, zvol, size, blocksize, mapped, created_at FROM iscsi_luns WHERE target_id = ?`, targetID)
if err != nil { return nil, err }
defer rows.Close()
res := []map[string]any{}
for rows.Next() {
var id, zvol string
var lunID int
var size int
var blocksize int
var mapped int
var created time.Time
if err := rows.Scan(&id, &lunID, &zvol, &size, &blocksize, &mapped, &created); err != nil { return nil, err }
res = append(res, map[string]any{"id": id, "lun_id": lunID, "zvol": zvol, "size": size, "blocksize": blocksize, "mapped": mapped == 1, "created_at": created})
}
return res, nil
}
func (s *ISCSIService) GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error) {
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return nil, err }
portals := []map[string]any{}
rows, err := s.DB.QueryContext(ctx, `SELECT id, address, port FROM iscsi_portals WHERE target_id = ?`, targetID)
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
var id, address string
var port int
if err := rows.Scan(&id, &address, &port); err != nil { return nil, err }
portals = append(portals, map[string]any{"id": id, "address": address, "port": port})
}
inits := []map[string]any{}
rows2, err := s.DB.QueryContext(ctx, `SELECT id, initiator_iqn FROM iscsi_initiators WHERE target_id = ?`, targetID)
if err != nil { return nil, err }
defer rows2.Close()
for rows2.Next() {
var id, iqnStr string
if err := rows2.Scan(&id, &iqnStr); err != nil { return nil, err }
inits = append(inits, map[string]any{"id": id, "iqn": iqnStr})
}
res := map[string]any{"iqn": iqn, "portals": portals, "initiators": inits}
return res, nil
}
func (s *ISCSIService) DeleteLUN(ctx context.Context, user, role, id string, force bool) error {
if role != "admin" { return ErrForbidden }
// check LUN
var mappedInt int
var targetID string
var lunID int
if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id, mapped FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID, &mappedInt); err != nil { return err }
if mappedInt == 1 && !force { return errors.New("LUN is mapped; unmap (drain) before deletion or specify force") }
// delete via adapter
if s.ISCSI != nil {
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err }
if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err }
if err := s.ISCSI.Save(ctx); err != nil { return err }
}
if _, err := s.DB.ExecContext(ctx, `DELETE FROM iscsi_luns WHERE id = ?`, id); err != nil { return err }
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.delete", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
return nil
}
// UnmapLUN removes LUN mapping from target, sets mapped to false in DB
func (s *ISCSIService) UnmapLUN(ctx context.Context, user, role, id string) error {
if role != "admin" && role != "operator" { return ErrForbidden }
var targetID string
var lunID int
if err := s.DB.QueryRowContext(ctx, `SELECT target_id, lun_id FROM iscsi_luns WHERE id = ?`, id).Scan(&targetID, &lunID); err != nil { return err }
if s.ISCSI != nil {
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return err }
if err := s.ISCSI.DeleteLUN(ctx, iqn, lunID); err != nil { return err }
if err := s.ISCSI.Save(ctx); err != nil { return err }
}
if _, err := s.DB.ExecContext(ctx, `UPDATE iscsi_luns SET mapped = 0 WHERE id = ?`, id); err != nil { return err }
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.lun.unmap", ResourceType: "iscsi_lun", ResourceID: id, Success: true}) }
return nil
}
func (s *ISCSIService) AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error) {
if role != "admin" && role != "operator" { return "", ErrForbidden }
// verify target exists and fetch IQN
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err }
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_portals (id, target_id, address, port) VALUES (?, ?, ?, ?)`, id, targetID, address, port); err != nil { return "", err }
if s.ISCSI != nil {
if err := s.ISCSI.AddPortal(ctx, iqn, address, port); err != nil { return "", err }
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
}
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.portal.add", ResourceType: "iscsi_portal", ResourceID: id, Success: true}) }
return id, nil
}
func (s *ISCSIService) AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error) {
if role != "admin" && role != "operator" { return "", ErrForbidden }
var iqn string
if err := s.DB.QueryRowContext(ctx, `SELECT iqn FROM iscsi_targets WHERE id = ?`, targetID).Scan(&iqn); err != nil { return "", err }
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO iscsi_initiators (id, target_id, initiator_iqn) VALUES (?, ?, ?)`, id, targetID, initiatorIQN); err != nil { return "", err }
if s.ISCSI != nil {
if err := s.ISCSI.AddACL(ctx, iqn, initiatorIQN); err != nil { return "", err }
if err := s.ISCSI.Save(ctx); err != nil { return "", err }
}
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "iscsi.initiator.add", ResourceType: "iscsi_initiator", ResourceID: id, Success: true}) }
return id, nil
}

View File

@@ -10,9 +10,11 @@ import (
)
var (
_ service.DiskService = (*MockDiskService)(nil)
_ service.ZFSService = (*MockZFSService)(nil)
_ service.JobRunner = (*MockJobRunner)(nil)
_ service.DiskService = (*MockDiskService)(nil)
_ service.ZFSService = (*MockZFSService)(nil)
_ service.JobRunner = (*MockJobRunner)(nil)
_ service.SharesService = (*MockSharesService)(nil)
_ service.ISCSIService = (*MockISCSIService)(nil)
)
type MockDiskService struct{}
@@ -32,8 +34,32 @@ func (m *MockZFSService) ListPools(ctx context.Context) ([]domain.Pool, error) {
}
func (m *MockZFSService) CreatePool(ctx context.Context, name string, vdevs []string) (string, error) {
// spawn instant job id for mock
return "job-" + uuid.New().String(), nil
// not implemented on adapter-level mock
return "", nil
}
func (m *MockZFSService) GetPoolStatus(ctx context.Context, pool string) (domain.PoolHealth, error) {
return domain.PoolHealth{Pool: pool, Status: "ONLINE", Detail: "mocked"}, nil
}
func (m *MockZFSService) ListDatasets(ctx context.Context, pool string) ([]domain.Dataset, error) {
return []domain.Dataset{{Name: pool + "/dataset1", Pool: pool, Type: "filesystem"}}, nil
}
func (m *MockZFSService) CreateDataset(ctx context.Context, name string, props map[string]string) error {
return nil
}
func (m *MockZFSService) Snapshot(ctx context.Context, dataset, snapName string) error {
return nil
}
func (m *MockZFSService) ScrubStart(ctx context.Context, pool string) error {
return nil
}
func (m *MockZFSService) ScrubStatus(ctx context.Context, pool string) (string, error) {
return "none", nil
}
type MockJobRunner struct{}
@@ -45,3 +71,67 @@ func (m *MockJobRunner) Enqueue(ctx context.Context, j domain.Job) (string, erro
}()
return uuid.New().String(), nil
}
type MockSharesService struct{}
func (m *MockSharesService) ListNFS(ctx context.Context) ([]domain.Share, error) {
return []domain.Share{{ID: domain.UUID(uuid.New().String()), Name: "data", Path: "tank/ds", Type: "nfs"}}, nil
}
func (m *MockSharesService) CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error) {
return "share-" + uuid.New().String(), nil
}
func (m *MockSharesService) DeleteNFS(ctx context.Context, user, role, id string) error {
return nil
}
func (m *MockSharesService) NFSStatus(ctx context.Context) (string, error) {
return "active", nil
}
func (m *MockSharesService) ListSMB(ctx context.Context) ([]domain.Share, error) {
return []domain.Share{{ID: domain.UUID(uuid.New().String()), Name: "smb1", Path: "tank/ds", Type: "smb", Config: map[string]string{"read_only": "false"}}}, nil
}
func (m *MockSharesService) CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error) {
return "smb-" + uuid.New().String(), nil
}
func (m *MockSharesService) DeleteSMB(ctx context.Context, user, role, id string) error {
return nil
}
type MockISCSIService struct{}
func (m *MockISCSIService) ListTargets(ctx context.Context) ([]map[string]any, error) {
return []map[string]any{{"id": "t-1", "iqn": "iqn.2025-12.org.example:target1", "name": "test"}}, nil
}
func (m *MockISCSIService) CreateTarget(ctx context.Context, user, role, name, iqn string) (string, error) {
return "t-" + uuid.New().String(), nil
}
func (m *MockISCSIService) CreateLUN(ctx context.Context, user, role, targetID, lunName string, size string, blocksize int) (string, error) {
return "lun-" + uuid.New().String(), nil
}
func (m *MockISCSIService) DeleteLUN(ctx context.Context, user, role, id string, force bool) error {
return nil
}
func (m *MockISCSIService) ListLUNs(ctx context.Context, targetID string) ([]map[string]any, error) {
return []map[string]any{{"id": "lun-1", "lun_id": 0, "zvol": "tank/ds/vol1", "size": 10737418240}}, nil
}
func (m *MockISCSIService) UnmapLUN(ctx context.Context, user, role, id string) error {
return nil
}
func (m *MockISCSIService) AddPortal(ctx context.Context, user, role, targetID, address string, port int) (string, error) {
return "portal-" + uuid.New().String(), nil
}
func (m *MockISCSIService) AddInitiator(ctx context.Context, user, role, targetID, initiatorIQN string) (string, error) {
return "init-" + uuid.New().String(), nil
}
func (m *MockISCSIService) GetTargetInfo(ctx context.Context, targetID string) (map[string]any, error) {
return map[string]any{"iqn": "iqn.2025-12.org.example:target1", "portals": []map[string]any{{"id": "p-1", "address": "10.0.0.1", "port": 3260}}, "initiators": []map[string]any{{"id": "i-1", "iqn": "iqn.1993-08.org.debian:01"}}}, nil
}

View File

@@ -0,0 +1,159 @@
package objectstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/example/storage-appliance/internal/audit"
"github.com/example/storage-appliance/internal/infra/minio"
"github.com/example/storage-appliance/internal/infra/crypto"
)
var (
ErrForbidden = errors.New("forbidden")
)
type Settings struct {
ID string
Name string
AccessKey string
SecretKey string
DataPath string
Port int
TLS bool
CreatedAt time.Time
}
type ObjectService struct {
DB *sql.DB
Minio *minio.Adapter
Audit audit.AuditLogger
// encryption key for secret storage
Key []byte
}
func NewObjectService(db *sql.DB, m *minio.Adapter, a audit.AuditLogger, key []byte) *ObjectService {
return &ObjectService{DB: db, Minio: m, Audit: a, Key: key}
}
func (s *ObjectService) SetSettings(ctx context.Context, user, role string, stMap map[string]any) error {
if role != "admin" {
return ErrForbidden
}
// convert map to Settings struct for local use
st := Settings{}
if v, ok := stMap["access_key"].(string); ok { st.AccessKey = v }
if v, ok := stMap["secret_key"].(string); ok { st.SecretKey = v }
if v, ok := stMap["data_path"].(string); ok { st.DataPath = v }
if v, ok := stMap["name"].(string); ok { st.Name = v }
if v, ok := stMap["port"].(int); ok { st.Port = v }
if v, ok := stMap["tls"].(bool); ok { st.TLS = v }
// encrypt access key and secret key
if len(s.Key) != 32 {
return errors.New("encryption key must be 32 bytes")
}
encAccess, err := crypto.Encrypt(s.Key, st.AccessKey)
if err != nil {
return err
}
encSecret, err := crypto.Encrypt(s.Key, st.SecretKey)
if err != nil {
return err
}
// upsert into DB (single row)
if _, err := s.DB.ExecContext(ctx, `INSERT OR REPLACE INTO object_storage (id, name, access_key, secret_key, data_path, port, tls) VALUES ('minio', ?, ?, ?, ?, ?, ?)` , st.Name, encAccess, encSecret, st.DataPath, st.Port, boolToInt(st.TLS)); err != nil {
return err
}
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "object.settings.update", ResourceType: "object_storage", ResourceID: "minio", Success: true})
}
if s.Minio != nil {
// write env file
settings := minio.Settings{AccessKey: st.AccessKey, SecretKey: st.SecretKey, DataPath: st.DataPath, Port: st.Port, TLS: st.TLS}
if err := s.Minio.WriteEnv(ctx, settings); err != nil {
return err
}
if err := s.Minio.Reload(ctx); err != nil {
return err
}
}
return nil
}
func (s *ObjectService) GetSettings(ctx context.Context) (map[string]any, error) {
var st Settings
row := s.DB.QueryRowContext(ctx, `SELECT name, access_key, secret_key, data_path, port, tls, created_at FROM object_storage WHERE id = 'minio'`)
var encAccess, encSecret string
var tlsInt int
if err := row.Scan(&st.Name, &encAccess, &encSecret, &st.DataPath, &st.Port, &tlsInt, &st.CreatedAt); err != nil {
return nil, err
}
st.TLS = tlsInt == 1
if len(s.Key) == 32 {
if A, err := crypto.Decrypt(s.Key, encAccess); err == nil { st.AccessKey = A }
if S, err := crypto.Decrypt(s.Key, encSecret); err == nil { st.SecretKey = S }
}
res := map[string]any{"name": st.Name, "access_key": st.AccessKey, "secret_key": st.SecretKey, "data_path": st.DataPath, "port": st.Port, "tls": st.TLS, "created_at": st.CreatedAt}
return res, nil
}
func boolToInt(b bool) int { if b { return 1 }; return 0 }
// ListBuckets via minio adapter or fallback to DB
func (s *ObjectService) ListBuckets(ctx context.Context) ([]string, error) {
if s.Minio != nil {
// ensure mc alias is configured
stMap, err := s.GetSettings(ctx)
if err != nil { return nil, err }
alias := "appliance"
mSet := minio.Settings{}
if v, ok := stMap["access_key"].(string); ok { mSet.AccessKey = v }
if v, ok := stMap["secret_key"].(string); ok { mSet.SecretKey = v }
if v, ok := stMap["data_path"].(string); ok { mSet.DataPath = v }
if v, ok := stMap["port"].(int); ok { mSet.Port = v }
if v, ok := stMap["tls"].(bool); ok { mSet.TLS = v }
s.Minio.ConfigureMC(ctx, alias, mSet)
return s.Minio.ListBuckets(ctx, alias)
}
// fallback to DB persisted buckets
rows, err := s.DB.QueryContext(ctx, `SELECT name FROM buckets`)
if err != nil { return nil, err }
defer rows.Close()
var res []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil { return nil, err }
res = append(res, name)
}
return res, nil
}
func (s *ObjectService) CreateBucket(ctx context.Context, user, role, name string) (string, error) {
if role != "admin" && role != "operator" { return "", ErrForbidden }
// attempt via minio adapter
if s.Minio != nil {
stMap, err := s.GetSettings(ctx)
if err != nil { return "", err }
alias := "appliance"
mSet := minio.Settings{}
if v, ok := stMap["access_key"].(string); ok { mSet.AccessKey = v }
if v, ok := stMap["secret_key"].(string); ok { mSet.SecretKey = v }
if v, ok := stMap["data_path"].(string); ok { mSet.DataPath = v }
if v, ok := stMap["port"].(int); ok { mSet.Port = v }
if v, ok := stMap["tls"].(bool); ok { mSet.TLS = v }
if err := s.Minio.ConfigureMC(ctx, alias, mSet); err != nil { return "", err }
if err := s.Minio.CreateBucket(ctx, alias, name); err != nil { return "", err }
// persist
id := fmt.Sprintf("bucket-%d", time.Now().UnixNano())
if _, err := s.DB.ExecContext(ctx, `INSERT INTO buckets (id, name) VALUES (?, ?)`, id, name); err != nil {
return "", err
}
if s.Audit != nil { s.Audit.Record(ctx, audit.Event{UserID: user, Action: "object.bucket.create", ResourceType: "bucket", ResourceID: name, Success: true}) }
return id, nil
}
return "", errors.New("no minio adapter configured")
}

View File

@@ -0,0 +1,225 @@
package shares
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"strings"
"github.com/example/storage-appliance/internal/audit"
"github.com/example/storage-appliance/internal/domain"
"github.com/example/storage-appliance/internal/infra/nfs"
"github.com/example/storage-appliance/internal/infra/samba"
)
var (
ErrForbidden = errors.New("forbidden")
)
type SharesService struct {
DB *sql.DB
NFS *nfs.Adapter
Samba *samba.Adapter
Audit audit.AuditLogger
}
func NewSharesService(db *sql.DB, n *nfs.Adapter, s *samba.Adapter, a audit.AuditLogger) *SharesService {
return &SharesService{DB: db, NFS: n, Samba: s, Audit: a}
}
func (s *SharesService) ListNFS(ctx context.Context) ([]domain.Share, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, path, type, options FROM shares WHERE type = 'nfs'`)
if err != nil {
return nil, err
}
defer rows.Close()
var res []domain.Share
for rows.Next() {
var id, name, path, typ, options string
if err := rows.Scan(&id, &name, &path, &typ, &options); err != nil {
return nil, err
}
var optMap map[string]string
if options != "" {
_ = json.Unmarshal([]byte(options), &optMap)
}
res = append(res, domain.Share{ID: domain.UUID(id), Name: name, Path: path, Type: typ})
}
return res, nil
}
// CreateNFS stores a new NFS export, re-renders /etc/exports and applies it
func (s *SharesService) CreateNFS(ctx context.Context, user, role, name, path string, opts map[string]string) (string, error) {
if role != "admin" && role != "operator" {
return "", ErrForbidden
}
// Verify path exists and is a dataset: check dataset table for matching name
var count int
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(1) FROM datasets WHERE name = ?`, path).Scan(&count); err != nil {
return "", err
}
if count == 0 {
return "", fmt.Errorf("path not a known dataset: %s", path)
}
// Prevent exporting system paths: disallow leading '/' entries; require dataset name like pool/ds
if path == "/" || path == "/etc" || path == "/bin" || path == "/usr" {
return "", fmt.Errorf("can't export system path: %s", path)
}
// store options as JSON
optJSON, _ := json.Marshal(opts)
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO shares (id, name, path, type, options) VALUES (?, ?, ?, 'nfs', ?)`, id, name, path, string(optJSON)); err != nil {
return "", err
}
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "nfs.create", ResourceType: "share", ResourceID: name, Success: true, Details: map[string]any{"path": path}})
}
// re-render exports
shares, err := s.ListNFS(ctx)
if err != nil {
return id, err
}
if s.NFS != nil {
if err := s.NFS.RenderExports(ctx, shares); err != nil {
return id, err
}
if err := s.NFS.Apply(ctx); err != nil {
return id, err
}
}
return id, nil
}
// SMB functions
func (s *SharesService) ListSMB(ctx context.Context) ([]domain.Share, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, path, type, options FROM shares WHERE type = 'smb'`)
if err != nil {
return nil, err
}
defer rows.Close()
var res []domain.Share
for rows.Next() {
var id, name, path, typ, options string
if err := rows.Scan(&id, &name, &path, &typ, &options); err != nil {
return nil, err
}
var config map[string]string
if options != "" {
_ = json.Unmarshal([]byte(options), &config)
}
res = append(res, domain.Share{ID: domain.UUID(id), Name: name, Path: path, Type: typ, Config: config})
}
return res, nil
}
func (s *SharesService) CreateSMB(ctx context.Context, user, role, name, path string, readOnly bool, allowedUsers []string) (string, error) {
if role != "admin" && role != "operator" {
return "", ErrForbidden
}
// Verify dataset
var count int
if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(1) FROM datasets WHERE name = ?`, path).Scan(&count); err != nil {
return "", err
}
if count == 0 {
return "", fmt.Errorf("path not a known dataset: %s", path)
}
// disallow system paths by basic checks
if path == "/" || path == "/etc" || path == "/bin" || path == "/usr" {
return "", fmt.Errorf("can't export system path: %s", path)
}
// store options as JSON (read_only, allowed_users)
cfg := map[string]string{}
if readOnly {
cfg["read_only"] = "true"
} else {
cfg["read_only"] = "false"
}
if len(allowedUsers) > 0 {
cfg["allowed_users"] = strings.Join(allowedUsers, " ")
}
optJSON, _ := json.Marshal(cfg)
id := uuid.New().String()
if _, err := s.DB.ExecContext(ctx, `INSERT INTO shares (id, name, path, type, options) VALUES (?, ?, ?, 'smb', ?)`, id, name, path, string(optJSON)); err != nil {
return "", err
}
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "smb.create", ResourceType: "share", ResourceID: name, Success: true, Details: map[string]any{"path": path, "read_only": readOnly}})
}
// re-render smb conf and reload
shares, err := s.ListSMB(ctx)
if err != nil {
return id, err
}
if s.Samba != nil {
if err := s.Samba.RenderConf(ctx, shares); err != nil {
return id, err
}
if err := s.Samba.Reload(ctx); err != nil {
return id, err
}
}
return id, nil
}
func (s *SharesService) DeleteSMB(ctx context.Context, user, role, id string) error {
if role != "admin" && role != "operator" {
return ErrForbidden
}
if _, err := s.DB.ExecContext(ctx, `DELETE FROM shares WHERE id = ?`, id); err != nil {
return err
}
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "smb.delete", ResourceType: "share", ResourceID: id, Success: true})
}
shares, err := s.ListSMB(ctx)
if err != nil {
return err
}
if s.Samba != nil {
if err := s.Samba.RenderConf(ctx, shares); err != nil {
return err
}
if err := s.Samba.Reload(ctx); err != nil {
return err
}
}
return nil
}
func (s *SharesService) DeleteNFS(ctx context.Context, user, role, id string) error {
if role != "admin" && role != "operator" {
return ErrForbidden
}
// verify exists
if _, err := s.DB.ExecContext(ctx, `DELETE FROM shares WHERE id = ?`, id); err != nil {
return err
}
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "nfs.delete", ResourceType: "share", ResourceID: id, Success: true})
}
// re-render exports
shares, err := s.ListNFS(ctx)
if err != nil {
return err
}
if s.NFS != nil {
if err := s.NFS.RenderExports(ctx, shares); err != nil {
return err
}
if err := s.NFS.Apply(ctx); err != nil {
return err
}
}
return nil
}
func (s *SharesService) NFSStatus(ctx context.Context) (string, error) {
if s.NFS == nil {
return "unavailable", nil
}
return s.NFS.Status(ctx)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/example/storage-appliance/internal/audit"
"github.com/example/storage-appliance/internal/domain"
@@ -49,6 +48,7 @@ func (s *StorageService) CreatePool(ctx context.Context, user string, role strin
}
// Create a job to build a pool. For skeleton, we just create a job entry with type create-pool
j := domain.Job{Type: "create-pool", Status: "queued", Owner: domain.UUID(user)}
j.Details = map[string]any{"name": name, "vdevs": vdevs}
id, err := s.JobRunner.Enqueue(ctx, j)
// Store details in audit
if s.Audit != nil {
@@ -64,6 +64,7 @@ func (s *StorageService) Snapshot(ctx context.Context, user, role, dataset, snap
}
// call zfs snapshot, but do as job; enqueue
j := domain.Job{Type: "snapshot", Status: "queued", Owner: domain.UUID(user)}
j.Details = map[string]any{"dataset": dataset, "snap_name": snapName}
id, err := s.JobRunner.Enqueue(ctx, j)
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "dataset.snapshot.request", ResourceType: "snapshot", ResourceID: fmt.Sprintf("%s@%s", dataset, snapName), Success: err == nil, Details: map[string]any{"dataset": dataset}})
@@ -76,6 +77,7 @@ func (s *StorageService) ScrubStart(ctx context.Context, user, role, pool string
return "", ErrForbidden
}
j := domain.Job{Type: "scrub", Status: "queued", Owner: domain.UUID(user)}
j.Details = map[string]any{"pool": pool}
id, err := s.JobRunner.Enqueue(ctx, j)
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "pool.scrub.request", ResourceType: "pool", ResourceID: pool, Success: err == nil})
@@ -93,7 +95,11 @@ func (s *StorageService) CreateDataset(ctx context.Context, user, role, name str
if role != "admin" && role != "operator" {
return ErrForbidden
}
return s.ZFS.CreateDataset(ctx, name, props)
err := s.ZFS.CreateDataset(ctx, name, props)
if s.Audit != nil {
s.Audit.Record(ctx, audit.Event{UserID: user, Action: "dataset.create", ResourceType: "dataset", ResourceID: name, Success: err == nil, Details: map[string]any{"props": props}})
}
return err
}
// GetPoolStatus calls the adapter