611f736088
- 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
331 lines
8.4 KiB
Go
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
|
|
}
|