354 lines
8.3 KiB
Go
354 lines
8.3 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
ErrConflict = errors.New("already exists")
|
|
ErrKeysLocked = errors.New("key pairs already exist and cannot be overwritten")
|
|
)
|
|
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
path string
|
|
doc document
|
|
}
|
|
|
|
func OpenStore(path string) (*Store, error) {
|
|
s := &Store{path: path, doc: document{Version: 1, Users: map[string]User{}, Licenses: map[string]LicenseRecord{}, Sessions: map[string]Session{}, Audit: []AuditEvent{}}}
|
|
if strings.TrimSpace(path) == "" {
|
|
return s, nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return s, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(data, &s.doc); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.doc.Version != 1 {
|
|
return nil, errors.New("unsupported data-store version")
|
|
}
|
|
if s.doc.Users == nil {
|
|
s.doc.Users = map[string]User{}
|
|
}
|
|
if s.doc.Licenses == nil {
|
|
s.doc.Licenses = map[string]LicenseRecord{}
|
|
}
|
|
if s.doc.Sessions == nil {
|
|
s.doc.Sessions = map[string]Session{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) KeySet() (*KeySet, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.doc.Keys == nil {
|
|
return nil, false
|
|
}
|
|
copy := *s.doc.Keys
|
|
return ©, true
|
|
}
|
|
|
|
func (s *Store) SetKeysOnce(keys KeySet) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.doc.Keys != nil {
|
|
return ErrKeysLocked
|
|
}
|
|
keys.CreatedAt = unixNow()
|
|
s.doc.Keys = &keys
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) CreateUser(user User) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
username := normalizeUsername(user.Username)
|
|
if !validUsername(username) {
|
|
return errors.New("username must be 3-120 characters and contain only letters, numbers, dot, dash, underscore or @")
|
|
}
|
|
if len(strings.TrimSpace(user.DisplayName)) > 200 {
|
|
return errors.New("display name is too long")
|
|
}
|
|
for _, existing := range s.doc.Users {
|
|
if normalizeUsername(existing.Username) == username {
|
|
return ErrConflict
|
|
}
|
|
}
|
|
now := unixNow()
|
|
user.Username = username
|
|
user.CreatedAt = now
|
|
user.UpdatedAt = now
|
|
if !user.Active {
|
|
user.Active = true
|
|
}
|
|
s.doc.Users[user.ID] = user
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) EnsureBootstrapAdmin(username, displayName, passwordHash string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, user := range s.doc.Users {
|
|
if user.Role == RoleAdmin {
|
|
return false, nil
|
|
}
|
|
}
|
|
username = normalizeUsername(username)
|
|
if !validUsername(username) {
|
|
return false, errors.New("bootstrap username is invalid")
|
|
}
|
|
now := unixNow()
|
|
user := User{ID: "usr_admin_bootstrap", Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdmin, PasswordHash: passwordHash, Active: true, CreatedAt: now, UpdatedAt: now}
|
|
if user.DisplayName == "" {
|
|
user.DisplayName = "Administrator"
|
|
}
|
|
s.doc.Users[user.ID] = user
|
|
return true, s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) FindUserByUsername(username string) (User, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
username = normalizeUsername(username)
|
|
for _, user := range s.doc.Users {
|
|
if normalizeUsername(user.Username) == username {
|
|
return user, true
|
|
}
|
|
}
|
|
return User{}, false
|
|
}
|
|
|
|
func (s *Store) GetUser(id string) (User, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
user, ok := s.doc.Users[id]
|
|
return user, ok
|
|
}
|
|
|
|
func (s *Store) UpdatePassword(userID, passwordHash string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
user, ok := s.doc.Users[userID]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
user.PasswordHash = passwordHash
|
|
user.UpdatedAt = unixNow()
|
|
s.doc.Users[userID] = user
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) ListUsers() []User {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]User, 0, len(s.doc.Users))
|
|
for _, user := range s.doc.Users {
|
|
out = append(out, user)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Role == out[j].Role {
|
|
return out[i].Username < out[j].Username
|
|
}
|
|
return out[i].Role < out[j].Role
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (s *Store) PutLicense(record LicenseRecord) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := unixNow()
|
|
if existing, ok := s.doc.Licenses[record.LicenseID]; ok {
|
|
record.CreatedAt = existing.CreatedAt
|
|
}
|
|
if record.CreatedAt == 0 {
|
|
record.CreatedAt = now
|
|
}
|
|
record.UpdatedAt = now
|
|
s.doc.Licenses[record.LicenseID] = record
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) GetLicense(id string) (LicenseRecord, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
record, ok := s.doc.Licenses[id]
|
|
return cloneLicense(record), ok
|
|
}
|
|
|
|
func (s *Store) ListLicensesFor(user User) []LicenseRecord {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]LicenseRecord, 0, len(s.doc.Licenses))
|
|
for _, record := range s.doc.Licenses {
|
|
switch user.Role {
|
|
case RoleAdmin:
|
|
out = append(out, cloneLicense(record))
|
|
case RoleReseller:
|
|
if record.IssuedByUserID == user.ID {
|
|
out = append(out, cloneLicense(record))
|
|
}
|
|
case RoleCustomer:
|
|
if record.CustomerUserID == user.ID {
|
|
out = append(out, cloneLicense(record))
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
|
|
return out
|
|
}
|
|
|
|
func (s *Store) SetRevoked(id string, revoked bool, reason string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
record, ok := s.doc.Licenses[id]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
record.Revoked = revoked
|
|
if revoked {
|
|
record.RevocationReason = strings.TrimSpace(reason)
|
|
} else {
|
|
record.RevocationReason = ""
|
|
}
|
|
record.UpdatedAt = unixNow()
|
|
s.doc.Licenses[id] = record
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) CreateSession(session Session) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cleanupSessionsLocked(time.Now().UTC())
|
|
s.doc.Sessions[session.IDHash] = session
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) GetSession(idHash string, now time.Time) (Session, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
session, ok := s.doc.Sessions[idHash]
|
|
if !ok || session.ExpiresAt <= now.Unix() {
|
|
return Session{}, false
|
|
}
|
|
return session, true
|
|
}
|
|
|
|
func (s *Store) DeleteSessionsForUser(userID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for id, session := range s.doc.Sessions {
|
|
if session.UserID == userID {
|
|
delete(s.doc.Sessions, id)
|
|
}
|
|
}
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) DeleteSession(idHash string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.doc.Sessions, idHash)
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) AddAudit(event AuditEvent) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if event.CreatedAt == 0 {
|
|
event.CreatedAt = unixNow()
|
|
}
|
|
s.doc.Audit = append(s.doc.Audit, event)
|
|
if len(s.doc.Audit) > 2000 {
|
|
s.doc.Audit = append([]AuditEvent(nil), s.doc.Audit[len(s.doc.Audit)-2000:]...)
|
|
}
|
|
return s.persistLocked()
|
|
}
|
|
|
|
func (s *Store) ListAudit(limit int) []AuditEvent {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
start := len(s.doc.Audit) - limit
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
out := append([]AuditEvent(nil), s.doc.Audit[start:]...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
|
return out
|
|
}
|
|
|
|
func (s *Store) persistLocked() error {
|
|
if strings.TrimSpace(s.path) == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(s.doc, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temp := s.path + ".tmp"
|
|
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temp, s.path)
|
|
}
|
|
|
|
func (s *Store) cleanupSessionsLocked(now time.Time) {
|
|
for key, session := range s.doc.Sessions {
|
|
if session.ExpiresAt <= now.Unix() {
|
|
delete(s.doc.Sessions, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeUsername(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func cloneLicense(record LicenseRecord) LicenseRecord {
|
|
record.Features = append([]string(nil), record.Features...)
|
|
record.Domains = append([]string(nil), record.Domains...)
|
|
record.InstanceIDs = append([]string(nil), record.InstanceIDs...)
|
|
if record.Limits != nil {
|
|
copy := make(map[string]int64, len(record.Limits))
|
|
for key, value := range record.Limits {
|
|
copy[key] = value
|
|
}
|
|
record.Limits = copy
|
|
}
|
|
return record
|
|
}
|
|
|
|
func validUsername(value string) bool {
|
|
if len(value) < 3 || len(value) > 120 {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == '@' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|