Files
GraceSolutions 611f736088 feat(auth): default admin/admin bootstrap with forced first-login password change
- Add password_reset_required column (migration 004) + repository support
- auth.Service.ChangePassword verifies current, hashes new, clears flag,
  emits PasswordChange audit events for success and failure
- Bootstrap: when no ORCHESTRAD_BOOTSTRAP_PASSWORD[_FILE] is set, seed
  admin/admin with password_reset_required=true and log a one-time warn
  banner; env/file-supplied passwords keep the flag clear
- Expose passwordResetRequired in UserInfo / /auth/me / login response
- POST /api/v1/auth/change-password behind the authenticated group
- Frontend: /change-password page + ChangePasswordForm, AuthLogin and
  RequireAuth bounce any other route to it while the flag is set
- Docs: DesignSpecification 8.6/8.8 and Template 7.6/7.7 rewritten,
  Trusted Proxy renumbered to 7.8 in the template, acceptance items
  updated to match the new default-credential behavior
2026-04-23 17:36:23 -04:00

331 lines
8.4 KiB
Go

// Package audit provides centralized audit event logging
package audit
import (
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
// EventType represents types of audit events
type EventType string
const (
EventLogin EventType = "Login"
EventLogout EventType = "Logout"
EventCreate EventType = "Create"
EventUpdate EventType = "Update"
EventDelete EventType = "Delete"
EventTest EventType = "Test"
EventRuleRun EventType = "RuleRun"
EventBackup EventType = "Backup"
EventRestore EventType = "Restore"
EventServiceStart EventType = "ServiceStart"
EventServiceStop EventType = "ServiceStop"
EventConfigChange EventType = "ConfigChange"
EventAPIKeyCreated EventType = "APIKeyCreated"
EventAPIKeyRevoked EventType = "APIKeyRevoked"
EventPasswordChange EventType = "PasswordChange"
)
// Event represents an audit event
type Event struct {
ID string
EventType EventType
Component string
UserID *string
Username *string
ResourceType *string
ResourceID *string
Action string
Details map[string]any
IPAddress *string
UserAgent *string
Success bool
ErrorMessage *string
CreatedUTC time.Time
}
// Service provides audit logging functionality
type Service struct {
db *sql.DB
}
// NewService creates a new audit service
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
// Log records an audit event
func (s *Service) Log(event Event) error {
if event.ID == "" {
event.ID = uuid.New().String()
}
if event.CreatedUTC.IsZero() {
event.CreatedUTC = time.Now().UTC()
}
var detailsJSON *string
if event.Details != nil {
bytes, err := json.Marshal(event.Details)
if err == nil {
str := string(bytes)
detailsJSON = &str
}
}
success := 1
if !event.Success {
success = 0
}
_, err := s.db.Exec(`
INSERT INTO audit_events (
id, event_type, component, user_id, username,
resource_type, resource_id, action, details_json,
ip_address, user_agent, success, error_message, created_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
event.ID, event.EventType, event.Component, event.UserID, event.Username,
event.ResourceType, event.ResourceID, event.Action, detailsJSON,
event.IPAddress, event.UserAgent, success, event.ErrorMessage,
event.CreatedUTC.Format(time.RFC3339),
)
return err
}
// LogSuccess is a convenience method for successful events
func (s *Service) LogSuccess(eventType EventType, component string, action string, details map[string]any) error {
return s.Log(Event{
EventType: eventType,
Component: component,
Action: action,
Details: details,
Success: true,
})
}
// LogFailure is a convenience method for failed events
func (s *Service) LogFailure(eventType EventType, component string, action string, errMsg string, details map[string]any) error {
return s.Log(Event{
EventType: eventType,
Component: component,
Action: action,
Details: details,
Success: false,
ErrorMessage: &errMsg,
})
}
// LogWithUser logs an event with user context
func (s *Service) LogWithUser(eventType EventType, component string, userID, username string, action string, details map[string]any) error {
return s.Log(Event{
EventType: eventType,
Component: component,
UserID: &userID,
Username: &username,
Action: action,
Details: details,
Success: true,
})
}
// LogResourceChange logs a CRUD operation on a resource
func (s *Service) LogResourceChange(eventType EventType, resourceType, resourceID string, userID *string, details map[string]any) error {
return s.Log(Event{
EventType: eventType,
Component: resourceType,
UserID: userID,
ResourceType: &resourceType,
ResourceID: &resourceID,
Action: string(eventType),
Details: details,
Success: true,
})
}
// Filter narrows audit event listings.
type Filter struct {
EventType string
UserID string
Username string
ResourceType string
ResourceID string
Action string
Success *bool
StartUTC *time.Time
EndUTC *time.Time
}
// List returns audit events matching the filter with pagination, plus the
// total row count before pagination. Events are ordered newest-first.
func (s *Service) List(filter Filter, offset, limit int) ([]Event, int, error) {
where, args := buildFilterClause(filter)
var total int
countQuery := "SELECT COUNT(*) FROM audit_events" + where
if err := s.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
listQuery := `
SELECT id, event_type, component, user_id, username,
resource_type, resource_id, action, details_json,
ip_address, user_agent, success, error_message, created_utc
FROM audit_events` + where + `
ORDER BY created_utc DESC
LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.Query(listQuery, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
events := make([]Event, 0)
for rows.Next() {
evt, err := scanEvent(rows)
if err != nil {
return nil, 0, err
}
events = append(events, evt)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return events, total, nil
}
// GetByID returns a single audit event by ID, or nil if not found.
func (s *Service) GetByID(id string) (*Event, error) {
row := s.db.QueryRow(`
SELECT id, event_type, component, user_id, username,
resource_type, resource_id, action, details_json,
ip_address, user_agent, success, error_message, created_utc
FROM audit_events WHERE id = ?`, id)
evt, err := scanEvent(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &evt, nil
}
// buildFilterClause constructs the WHERE clause and argument list for List.
func buildFilterClause(f Filter) (string, []any) {
var clauses []string
var args []any
if f.EventType != "" {
clauses = append(clauses, "event_type = ?")
args = append(args, f.EventType)
}
if f.UserID != "" {
clauses = append(clauses, "user_id = ?")
args = append(args, f.UserID)
}
if f.Username != "" {
clauses = append(clauses, "username = ?")
args = append(args, f.Username)
}
if f.ResourceType != "" {
clauses = append(clauses, "resource_type = ?")
args = append(args, f.ResourceType)
}
if f.ResourceID != "" {
clauses = append(clauses, "resource_id = ?")
args = append(args, f.ResourceID)
}
if f.Action != "" {
clauses = append(clauses, "action = ?")
args = append(args, f.Action)
}
if f.Success != nil {
v := 0
if *f.Success {
v = 1
}
clauses = append(clauses, "success = ?")
args = append(args, v)
}
if f.StartUTC != nil {
clauses = append(clauses, "created_utc >= ?")
args = append(args, f.StartUTC.Format(time.RFC3339))
}
if f.EndUTC != nil {
clauses = append(clauses, "created_utc <= ?")
args = append(args, f.EndUTC.Format(time.RFC3339))
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + joinAnd(clauses), args
}
func joinAnd(parts []string) string {
out := parts[0]
for i := 1; i < len(parts); i++ {
out += " AND " + parts[i]
}
return out
}
// rowScanner is the minimal interface implemented by *sql.Row and *sql.Rows.
type rowScanner interface {
Scan(dest ...any) error
}
func scanEvent(row rowScanner) (Event, error) {
var e Event
var userID, username, resourceType, resourceID sql.NullString
var detailsJSON, ipAddress, userAgent, errorMessage sql.NullString
var createdUTC string
var success int
if err := row.Scan(
&e.ID, &e.EventType, &e.Component, &userID, &username,
&resourceType, &resourceID, &e.Action, &detailsJSON,
&ipAddress, &userAgent, &success, &errorMessage, &createdUTC,
); err != nil {
return Event{}, err
}
if userID.Valid {
s := userID.String
e.UserID = &s
}
if username.Valid {
s := username.String
e.Username = &s
}
if resourceType.Valid {
s := resourceType.String
e.ResourceType = &s
}
if resourceID.Valid {
s := resourceID.String
e.ResourceID = &s
}
if ipAddress.Valid {
s := ipAddress.String
e.IPAddress = &s
}
if userAgent.Valid {
s := userAgent.String
e.UserAgent = &s
}
if errorMessage.Valid {
s := errorMessage.String
e.ErrorMessage = &s
}
if detailsJSON.Valid && detailsJSON.String != "" {
_ = json.Unmarshal([]byte(detailsJSON.String), &e.Details)
}
e.Success = success == 1
if t, err := time.Parse(time.RFC3339, createdUTC); err == nil {
e.CreatedUTC = t
}
return e, nil
}