|
| 1 | +// Package main demonstrates an enterprise audit-trail system using timefy. |
| 2 | +// |
| 3 | +// Real-world scenario: a financial services platform must: |
| 4 | +// |
| 5 | +// 1. Record every data-access event with nanosecond-precision UTC timestamps. |
| 6 | +// 2. Enforce a GDPR Article 33 compliance rule: any personal-data breach must |
| 7 | +// be reported to the supervisory authority within 72 hours of detection. |
| 8 | +// 3. Detect SLA breaches (e.g., a support ticket must be resolved within |
| 9 | +// 4 business hours of creation). |
| 10 | +// 4. Generate a daily digest of events grouped by hour for the CISO dashboard. |
| 11 | +// 5. Answer "how long ago was this event?" in human-readable form for the UI. |
| 12 | +// |
| 13 | +// Run with: |
| 14 | +// |
| 15 | +// cd _example/audit && go run main.go |
| 16 | +package main |
| 17 | + |
| 18 | +import ( |
| 19 | + "fmt" |
| 20 | + "sort" |
| 21 | + "time" |
| 22 | + |
| 23 | + "github.com/sivaosorg/timefy" |
| 24 | +) |
| 25 | + |
| 26 | +// Severity classifies an audit event. |
| 27 | +type Severity string |
| 28 | + |
| 29 | +const ( |
| 30 | + SeverityInfo Severity = "INFO" |
| 31 | + SeverityWarning Severity = "WARNING" |
| 32 | + SeverityCritical Severity = "CRITICAL" |
| 33 | + SeverityBreach Severity = "BREACH" |
| 34 | +) |
| 35 | + |
| 36 | +// AuditEvent is a single immutable audit record. |
| 37 | +type AuditEvent struct { |
| 38 | + ID string |
| 39 | + OccurredAt time.Time // stored in UTC, nanosecond precision |
| 40 | + ActorID string |
| 41 | + Action string |
| 42 | + Resource string |
| 43 | + Severity Severity |
| 44 | + Resolved bool |
| 45 | + ResolvedAt time.Time // zero value if unresolved |
| 46 | +} |
| 47 | + |
| 48 | +// HourBucket groups audit events that occurred within the same UTC hour. |
| 49 | +type HourBucket struct { |
| 50 | + Hour time.Time // beginning of the hour (UTC) |
| 51 | + Events []AuditEvent |
| 52 | +} |
| 53 | + |
| 54 | +// ───────────────────────────────────────────────────────────────────────────── |
| 55 | +// gdprBreachDeadline returns the timestamp by which a detected breach must be |
| 56 | +// reported. GDPR Article 33 mandates notification within 72 hours. |
| 57 | +// |
| 58 | +// Why timefy.AddHour(detectedAt, 72)? |
| 59 | +// - Using time.Add(72 * time.Hour) is equivalent here, but AddHour reads as |
| 60 | +// intent-revealing code in an audit context where "hours" is the business |
| 61 | +// unit mandated by law. |
| 62 | +// ───────────────────────────────────────────────────────────────────────────── |
| 63 | +func gdprBreachDeadline(detectedAt time.Time) time.Time { |
| 64 | + return timefy.AddHour(detectedAt, 72) |
| 65 | +} |
| 66 | + |
| 67 | +// isGDPRCompliant reports whether a breach detected at `detectedAt` has been |
| 68 | +// reported (i.e., is resolved) within the 72-hour window from `reportedAt`. |
| 69 | +func isGDPRCompliant(detectedAt, reportedAt time.Time) bool { |
| 70 | + deadline := gdprBreachDeadline(detectedAt) |
| 71 | + // IsWithinTolerance would be too loose (±1 minute). We use a direct |
| 72 | + // comparison: reported time must be before or exactly at the deadline. |
| 73 | + return !reportedAt.After(deadline) |
| 74 | +} |
| 75 | + |
| 76 | +// ───────────────────────────────────────────────────────────────────────────── |
| 77 | +// slaDeadline returns the SLA resolution deadline for a ticket created at |
| 78 | +// `createdAt`. The SLA clock runs for `businessHours` business hours (M-F, |
| 79 | +// 09:00-18:00 UTC for simplicity; adapt WithLocation for regional SLAs). |
| 80 | +// |
| 81 | +// Algorithm: |
| 82 | +// 1. Start at createdAt. |
| 83 | +// 2. Add one hour at a time, counting only hours that fall within business |
| 84 | +// hours on weekdays, until the required number of hours is consumed. |
| 85 | +// |
| 86 | +// Why not simply use AddHour(createdAt, businessHours)? |
| 87 | +// - SLAs in enterprise support contracts only count working hours. |
| 88 | +// A ticket created at 17:00 Friday with a 4-hour SLA should expire at |
| 89 | +// 13:00 Monday, not 21:00 Friday (which would already be closed). |
| 90 | +// ───────────────────────────────────────────────────────────────────────────── |
| 91 | +func slaDeadline(createdAt time.Time, businessHours int) time.Time { |
| 92 | + cursor := createdAt |
| 93 | + remaining := businessHours |
| 94 | + |
| 95 | + for remaining > 0 { |
| 96 | + cursor = timefy.AddHour(cursor, 1) |
| 97 | + tx := timefy.New(cursor) |
| 98 | + h := cursor.Hour() |
| 99 | + if tx.IsWeekday() && h >= 9 && h < 18 { |
| 100 | + remaining-- |
| 101 | + } |
| 102 | + } |
| 103 | + return cursor |
| 104 | +} |
| 105 | + |
| 106 | +// isSLABreached reports whether an event is unresolved and its SLA deadline |
| 107 | +// has passed. |
| 108 | +func isSLABreached(event AuditEvent, slaHours int) bool { |
| 109 | + if event.Resolved { |
| 110 | + return false |
| 111 | + } |
| 112 | + deadline := slaDeadline(event.OccurredAt, slaHours) |
| 113 | + return timefy.New(deadline).IsPast() |
| 114 | +} |
| 115 | + |
| 116 | +// ───────────────────────────────────────────────────────────────────────────── |
| 117 | +// bucketByHour groups a slice of AuditEvents into HourBuckets sorted by the |
| 118 | +// UTC hour in which they occurred. |
| 119 | +// |
| 120 | +// BeginningOfMinute (Truncate to minute) then BeginningOfHour gives us the |
| 121 | +// canonical hour key. Using timefy.New(e.OccurredAt).BeginningOfHour() is |
| 122 | +// simpler and more readable than the equivalent time.Truncate(time.Hour). |
| 123 | +// ───────────────────────────────────────────────────────────────────────────── |
| 124 | +func bucketByHour(events []AuditEvent) []HourBucket { |
| 125 | + bucketMap := make(map[time.Time]*HourBucket) |
| 126 | + |
| 127 | + for _, e := range events { |
| 128 | + hourKey := timefy.New(e.OccurredAt).BeginningOfHour() |
| 129 | + if _, exists := bucketMap[hourKey]; !exists { |
| 130 | + bucketMap[hourKey] = &HourBucket{Hour: hourKey} |
| 131 | + } |
| 132 | + bucketMap[hourKey].Events = append(bucketMap[hourKey].Events, e) |
| 133 | + } |
| 134 | + |
| 135 | + // Sort buckets chronologically. |
| 136 | + buckets := make([]HourBucket, 0, len(bucketMap)) |
| 137 | + for _, b := range bucketMap { |
| 138 | + buckets = append(buckets, *b) |
| 139 | + } |
| 140 | + sort.Slice(buckets, func(i, j int) bool { |
| 141 | + return buckets[i].Hour.Before(buckets[j].Hour) |
| 142 | + }) |
| 143 | + return buckets |
| 144 | +} |
| 145 | + |
| 146 | +// ───────────────────────────────────────────────────────────────────────────── |
| 147 | +// eventsInWindow returns all events whose OccurredAt falls within [start, end]. |
| 148 | +// |
| 149 | +// IsBetween(start, end) performs an inclusive range check on the Timex, so |
| 150 | +// events exactly at midnight or exactly at end-of-day are correctly included. |
| 151 | +// ───────────────────────────────────────────────────────────────────────────── |
| 152 | +func eventsInWindow(events []AuditEvent, start, end time.Time) []AuditEvent { |
| 153 | + var result []AuditEvent |
| 154 | + for _, e := range events { |
| 155 | + if timefy.New(e.OccurredAt).IsBetween(start, end) { |
| 156 | + result = append(result, e) |
| 157 | + } |
| 158 | + } |
| 159 | + return result |
| 160 | +} |
| 161 | + |
| 162 | +// ───────────────────────────────────────────────────────────────────────────── |
| 163 | +// formatComponents uses FormatTimex to extract individual time components for |
| 164 | +// a structured audit log line. |
| 165 | +// |
| 166 | +// FormatTimex returns [nanosecond, second, minute, hour, day, month, year] |
| 167 | +// which maps perfectly to a structured log field set. |
| 168 | +// ───────────────────────────────────────────────────────────────────────────── |
| 169 | +func structuredTimestamp(t time.Time) string { |
| 170 | + c := timefy.FormatTimex(t) |
| 171 | + // c = [ns, sec, min, hour, day, month, year] |
| 172 | + return fmt.Sprintf("%04d-%02d-%02dT%02d:%02d:%02d.%09d UTC", |
| 173 | + c[6], c[5], c[4], c[3], c[2], c[1], c[0]) |
| 174 | +} |
| 175 | + |
| 176 | +func main() { |
| 177 | + // ── Synthetic audit log (timestamps carefully chosen to exercise each rule) |
| 178 | + |
| 179 | + // Reference "now" for this demo run. |
| 180 | + now := time.Date(2025, time.March, 24, 14, 30, 0, 0, time.UTC) |
| 181 | + |
| 182 | + events := []AuditEvent{ |
| 183 | + // Normal access events today |
| 184 | + { |
| 185 | + ID: "evt-001", OccurredAt: time.Date(2025, 3, 24, 8, 15, 0, 0, time.UTC), |
| 186 | + ActorID: "alice", Action: "READ", Resource: "/api/users/42", |
| 187 | + Severity: SeverityInfo, |
| 188 | + }, |
| 189 | + { |
| 190 | + ID: "evt-002", OccurredAt: time.Date(2025, 3, 24, 9, 5, 30, 123456789, time.UTC), |
| 191 | + ActorID: "bob", Action: "WRITE", Resource: "/api/payments/99", |
| 192 | + Severity: SeverityInfo, |
| 193 | + }, |
| 194 | + // Warning: unusual bulk export |
| 195 | + { |
| 196 | + ID: "evt-003", OccurredAt: time.Date(2025, 3, 24, 10, 45, 0, 0, time.UTC), |
| 197 | + ActorID: "carol", Action: "EXPORT", Resource: "/api/users (bulk)", |
| 198 | + Severity: SeverityWarning, |
| 199 | + }, |
| 200 | + // Critical: privilege escalation attempted |
| 201 | + { |
| 202 | + ID: "evt-004", OccurredAt: time.Date(2025, 3, 24, 11, 0, 0, 0, time.UTC), |
| 203 | + ActorID: "dave", Action: "ESCALATE", Resource: "/admin/roles", |
| 204 | + Severity: SeverityCritical, Resolved: false, |
| 205 | + }, |
| 206 | + // BREACH: detected at 08:00, reported at 10:00 on the same day |
| 207 | + { |
| 208 | + ID: "evt-005", OccurredAt: time.Date(2025, 3, 22, 8, 0, 0, 0, time.UTC), |
| 209 | + ActorID: "system", Action: "DATA_LEAK", Resource: "/db/pii_records", |
| 210 | + Severity: SeverityBreach, Resolved: true, |
| 211 | + ResolvedAt: time.Date(2025, 3, 22, 10, 0, 0, 0, time.UTC), |
| 212 | + }, |
| 213 | + // BREACH: detected Saturday, reported 80 hours later — NON-COMPLIANT |
| 214 | + { |
| 215 | + ID: "evt-006", OccurredAt: time.Date(2025, 3, 21, 6, 0, 0, 0, time.UTC), |
| 216 | + ActorID: "system", Action: "UNAUTHORIZED_ACCESS", Resource: "/api/credit_cards", |
| 217 | + Severity: SeverityBreach, Resolved: true, |
| 218 | + ResolvedAt: time.Date(2025, 3, 24, 14, 0, 0, 0, time.UTC), // 80 hours later |
| 219 | + }, |
| 220 | + // Unresolved critical ticket — created Friday 17:30 UTC, SLA = 4 biz hours |
| 221 | + { |
| 222 | + ID: "evt-007", OccurredAt: time.Date(2025, 3, 21, 17, 30, 0, 0, time.UTC), |
| 223 | + ActorID: "frank", Action: "ACCESS_DENIED", Resource: "/api/reports", |
| 224 | + Severity: SeverityCritical, Resolved: false, |
| 225 | + }, |
| 226 | + } |
| 227 | + |
| 228 | + fmt.Println("═══════════════════════════════════════════════════════════") |
| 229 | + fmt.Println(" TIMEFY — ENTERPRISE AUDIT TRAIL DEMO ") |
| 230 | + fmt.Println("═══════════════════════════════════════════════════════════") |
| 231 | + |
| 232 | + // ── 1. Full audit log with human-readable age ───────────────────────────── |
| 233 | + fmt.Println("\n Full audit log:") |
| 234 | + fmt.Println(" ─────────────────────────────────────────────────────────────────────────") |
| 235 | + // We override "now" for TimeAgo by using DurationInDays / hours manually |
| 236 | + // when we need a frozen reference. Standard TimeAgo() uses time.Now() |
| 237 | + // internally, which is correct for a live system. |
| 238 | + for _, e := range events { |
| 239 | + age := timefy.New(e.OccurredAt).TimeAgo() |
| 240 | + ts := structuredTimestamp(e.OccurredAt) |
| 241 | + fmt.Printf(" [%s] %-12s %-15s %-10s %s (%s)\n", |
| 242 | + e.Severity, e.Action, e.Resource, e.ActorID, ts, age) |
| 243 | + } |
| 244 | + |
| 245 | + // ── 2. GDPR Article 33 compliance check ─────────────────────────────────── |
| 246 | + fmt.Println("\n GDPR Article 33 compliance (72-hour notification rule):") |
| 247 | + fmt.Println(" ─────────────────────────────────────────────────────────") |
| 248 | + for _, e := range events { |
| 249 | + if e.Severity != SeverityBreach { |
| 250 | + continue |
| 251 | + } |
| 252 | + deadline := gdprBreachDeadline(e.OccurredAt) |
| 253 | + if e.Resolved { |
| 254 | + compliant := isGDPRCompliant(e.OccurredAt, e.ResolvedAt) |
| 255 | + hoursUsed := int(e.ResolvedAt.Sub(e.OccurredAt).Hours()) |
| 256 | + icon := "✓ COMPLIANT" |
| 257 | + if !compliant { |
| 258 | + icon = "✗ NON-COMPLIANT" |
| 259 | + } |
| 260 | + fmt.Printf(" [%s] event %s detected %s deadline %s reported in %dh → %s\n", |
| 261 | + e.Severity, e.ID, |
| 262 | + e.OccurredAt.Format("01-02 15:04"), |
| 263 | + deadline.Format("01-02 15:04"), |
| 264 | + hoursUsed, icon) |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + // ── 3. SLA breach detection ──────────────────────────────────────────────── |
| 269 | + fmt.Println("\n SLA breach detection (4 business hours):") |
| 270 | + fmt.Println(" ─────────────────────────────────────────────────────────") |
| 271 | + for _, e := range events { |
| 272 | + if e.Severity != SeverityCritical { |
| 273 | + continue |
| 274 | + } |
| 275 | + deadline := slaDeadline(e.OccurredAt, 4) |
| 276 | + breached := isSLABreached(e, 4) |
| 277 | + hoursElapsed := int(now.Sub(e.OccurredAt).Hours()) |
| 278 | + icon := "✓ within SLA" |
| 279 | + if breached { |
| 280 | + icon = "✗ SLA BREACHED" |
| 281 | + } |
| 282 | + fmt.Printf(" event %-8s created %s SLA deadline %s elapsed %dh → %s\n", |
| 283 | + e.ID, |
| 284 | + e.OccurredAt.Format("Mon 01-02 15:04"), |
| 285 | + deadline.Format("Mon 01-02 15:04"), |
| 286 | + hoursElapsed, icon) |
| 287 | + } |
| 288 | + |
| 289 | + // ── 4. Events in today's window ─────────────────────────────────────────── |
| 290 | + todayStart := timefy.New(now).BeginningOfDay() |
| 291 | + todayEnd := timefy.New(now).EndOfDay() |
| 292 | + todayEvents := eventsInWindow(events, todayStart, todayEnd) |
| 293 | + fmt.Printf("\n Events on %s: %d total\n", now.Format("2006-01-02"), len(todayEvents)) |
| 294 | + |
| 295 | + // ── 5. Hourly digest for today ──────────────────────────────────────────── |
| 296 | + fmt.Println("\n Hourly digest (today only):") |
| 297 | + fmt.Println(" ─────────────────────────────────────────────────────────") |
| 298 | + buckets := bucketByHour(todayEvents) |
| 299 | + for _, b := range buckets { |
| 300 | + fmt.Printf(" %s UTC — %d event(s)\n", |
| 301 | + b.Hour.Format("15:04"), len(b.Events)) |
| 302 | + for _, e := range b.Events { |
| 303 | + fmt.Printf(" • [%s] %s %s by %s\n", |
| 304 | + e.Severity, e.Action, e.Resource, e.ActorID) |
| 305 | + } |
| 306 | + } |
| 307 | + |
| 308 | + // ── 6. Retention window: events older than 90 days can be archived ───────── |
| 309 | + fmt.Println("\n Retention check (90-day archive threshold):") |
| 310 | + fmt.Println(" ─────────────────────────────────────────────────────────") |
| 311 | + archiveThreshold := timefy.BeginOfDay(timefy.AddDay(now, -90)) |
| 312 | + for _, e := range events { |
| 313 | + if timefy.New(e.OccurredAt).IsBefore(archiveThreshold) { |
| 314 | + fmt.Printf(" ARCHIVE: %s occurred %s\n", |
| 315 | + e.ID, e.OccurredAt.Format("2006-01-02")) |
| 316 | + } |
| 317 | + } |
| 318 | + fmt.Println(" (no events in this demo are older than 90 days)") |
| 319 | + |
| 320 | + // ── 7. Calendar context for the CISO weekly report ──────────────────────── |
| 321 | + fmt.Println("\n CISO weekly report context:") |
| 322 | + fmt.Println(" ─────────────────────────────────────────────────────────") |
| 323 | + tx := timefy.New(now) |
| 324 | + fmt.Printf(" Current week : %s → %s\n", |
| 325 | + tx.BeginningOfWeek().Format("2006-01-02 Mon"), |
| 326 | + tx.EndOfWeek().Format("2006-01-02 Mon")) |
| 327 | + fmt.Printf(" Current quarter : Q%d %s → %s\n", |
| 328 | + tx.Quarter(), |
| 329 | + tx.BeginningOfQuarter().Format("2006-01-02"), |
| 330 | + tx.EndOfQuarter().Format("2006-01-02")) |
| 331 | + fmt.Printf(" Year-to-date : %s → %s\n", |
| 332 | + tx.BeginningOfYear().Format("2006-01-02"), |
| 333 | + now.Format("2006-01-02")) |
| 334 | +} |
0 commit comments