tenseleyflow/shithub / cad7ad5

Browse files

audit: webhook actions get their own enums + missing trails (SR2 H1+M4)

Pre-fix: webhook create/update overloaded ActionRepoCreated with
a meta.action discriminator. Webhook delete/toggle/ping/redeliver
were not audited at all — admin had no forensic record of who
disabled, deleted, or replayed a hook.

Post-fix:
- New audit actions: ActionWebhook{Created,Updated,Deleted,
ActiveSet,ActiveUnset,Pinged,Redelivered}.
- All 7 webhook handlers now audit, all attributed via
viewer.AuditActor so impersonation trails carry.
- Bonus: ActionAdminRepoForceUnarchived added in preparation for
SR2 H8 (split repoForceArchive into archive/unarchive).
Authored by espadonne
SHA
cad7ad5e52c51035dd871b333d96ce7f2ab2f587
Parents
03475a3
Tree
be99bf4

2 changed files

StatusFile+-
M internal/auth/audit/audit.go 25 13
M internal/web/handlers/repo/webhooks.go 31 7
internal/auth/audit/audit.gomodified
@@ -72,22 +72,34 @@ const (
72
 	ActionRepoForked            Action = "repo_forked"
72
 	ActionRepoForked            Action = "repo_forked"
73
 	ActionRepoForkSynced        Action = "repo_fork_synced"
73
 	ActionRepoForkSynced        Action = "repo_fork_synced"
74
 
74
 
75
+	// S33 / SR2 H1 — webhook lifecycle. Pre-SR2 webhook create/update
76
+	// overloaded ActionRepoCreated; delete/toggle/ping/redeliver were
77
+	// not audited at all. Each event now has its own enum.
78
+	ActionWebhookCreated     Action = "webhook_created"
79
+	ActionWebhookUpdated     Action = "webhook_updated"
80
+	ActionWebhookDeleted     Action = "webhook_deleted"
81
+	ActionWebhookActiveSet   Action = "webhook_active_set"
82
+	ActionWebhookActiveUnset Action = "webhook_active_unset"
83
+	ActionWebhookPinged      Action = "webhook_pinged"
84
+	ActionWebhookRedelivered Action = "webhook_redelivered"
85
+
75
 	// S34 — site admin actions. Always recorded with the real admin's
86
 	// S34 — site admin actions. Always recorded with the real admin's
76
 	// id in actor_id; impersonation flows additionally carry the
87
 	// id in actor_id; impersonation flows additionally carry the
77
 	// impersonated user's id in meta.impersonated_user_id.
88
 	// impersonated user's id in meta.impersonated_user_id.
78
-	ActionAdminSiteAdminGranted   Action = "admin_site_admin_granted"
89
+	ActionAdminSiteAdminGranted    Action = "admin_site_admin_granted"
79
-	ActionAdminSiteAdminRevoked   Action = "admin_site_admin_revoked"
90
+	ActionAdminSiteAdminRevoked    Action = "admin_site_admin_revoked"
80
-	ActionAdminUserSuspended      Action = "admin_user_suspended"
91
+	ActionAdminUserSuspended       Action = "admin_user_suspended"
81
-	ActionAdminUserUnsuspended    Action = "admin_user_unsuspended"
92
+	ActionAdminUserUnsuspended     Action = "admin_user_unsuspended"
82
-	ActionAdminUserForceDeleted   Action = "admin_user_force_deleted"
93
+	ActionAdminUserForceDeleted    Action = "admin_user_force_deleted"
83
-	ActionAdminUserPasswordReset  Action = "admin_user_password_reset"
94
+	ActionAdminUserPasswordReset   Action = "admin_user_password_reset"
84
-	ActionAdminRepoForceArchived  Action = "admin_repo_force_archived"
95
+	ActionAdminRepoForceArchived   Action = "admin_repo_force_archived"
85
-	ActionAdminRepoForceDeleted   Action = "admin_repo_force_deleted"
96
+	ActionAdminRepoForceUnarchived Action = "admin_repo_force_unarchived"
86
-	ActionAdminJobRetried         Action = "admin_job_retried"
97
+	ActionAdminRepoForceDeleted    Action = "admin_repo_force_deleted"
87
-	ActionAdminJobDiscarded       Action = "admin_job_discarded"
98
+	ActionAdminJobRetried          Action = "admin_job_retried"
88
-	ActionAdminImpersonateStarted Action = "admin_impersonate_started"
99
+	ActionAdminJobDiscarded        Action = "admin_job_discarded"
89
-	ActionAdminImpersonateStopped Action = "admin_impersonate_stopped"
100
+	ActionAdminImpersonateStarted  Action = "admin_impersonate_started"
90
-	ActionAdminImpersonateWriteOn Action = "admin_impersonate_write_on"
101
+	ActionAdminImpersonateStopped  Action = "admin_impersonate_stopped"
102
+	ActionAdminImpersonateWriteOn  Action = "admin_impersonate_write_on"
91
 )
103
 )
92
 
104
 
93
 // Target is a typed target-type constant.
105
 // Target is a typed target-type constant.
internal/web/handlers/repo/webhooks.gomodified
@@ -118,9 +118,9 @@ func (h *Handlers) webhookCreate(w http.ResponseWriter, r *http.Request) {
118
 		}, friendlyWebhookError(err), "")
118
 		}, friendlyWebhookError(err), "")
119
 		return
119
 		return
120
 	}
120
 	}
121
-	auditActor, auditMeta := viewer.AuditActor(map[string]any{"action": "webhook_created", "webhook_id": created.ID, "url": params.URL})
121
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{"webhook_id": created.ID, "url": params.URL})
122
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
122
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
123
-		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
123
+		audit.ActionWebhookCreated, audit.TargetRepo, row.ID,
124
 		auditMeta)
124
 		auditMeta)
125
 
125
 
126
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
126
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
@@ -180,9 +180,9 @@ func (h *Handlers) webhookUpdate(w http.ResponseWriter, r *http.Request) {
180
 		return
180
 		return
181
 	}
181
 	}
182
 	viewer := middleware.CurrentUserFromContext(r.Context())
182
 	viewer := middleware.CurrentUserFromContext(r.Context())
183
-	auditActor, auditMeta := viewer.AuditActor(map[string]any{"action": "webhook_updated", "webhook_id": hook.ID})
183
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{"webhook_id": hook.ID})
184
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
184
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
185
-		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
185
+		audit.ActionWebhookUpdated, audit.TargetRepo, row.ID,
186
 		auditMeta)
186
 		auditMeta)
187
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
187
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
188
 }
188
 }
@@ -204,9 +204,9 @@ func (h *Handlers) webhookDelete(w http.ResponseWriter, r *http.Request) {
204
 		return
204
 		return
205
 	}
205
 	}
206
 	viewer := middleware.CurrentUserFromContext(r.Context())
206
 	viewer := middleware.CurrentUserFromContext(r.Context())
207
-	auditActor, auditMeta := viewer.AuditActor(map[string]any{"action": "webhook_deleted", "webhook_id": hook.ID})
207
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{"webhook_id": hook.ID, "url": hook.Url})
208
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
208
 	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
209
-		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
209
+		audit.ActionWebhookDeleted, audit.TargetRepo, row.ID,
210
 		auditMeta)
210
 		auditMeta)
211
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
211
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
212
 }
212
 }
@@ -222,12 +222,22 @@ func (h *Handlers) webhookToggle(w http.ResponseWriter, r *http.Request) {
222
 	if !ok {
222
 	if !ok {
223
 		return
223
 		return
224
 	}
224
 	}
225
+	newActive := !hook.Active
225
 	if err := webhook.SetActive(r.Context(), webhook.ManageDeps{
226
 	if err := webhook.SetActive(r.Context(), webhook.ManageDeps{
226
 		Pool: h.d.Pool, SecretBox: h.d.SecretBox,
227
 		Pool: h.d.Pool, SecretBox: h.d.SecretBox,
227
-	}, hook.ID, !hook.Active); err != nil {
228
+	}, hook.ID, newActive); err != nil {
228
 		http.Error(w, "toggle failed", http.StatusInternalServerError)
229
 		http.Error(w, "toggle failed", http.StatusInternalServerError)
229
 		return
230
 		return
230
 	}
231
 	}
232
+	viewer := middleware.CurrentUserFromContext(r.Context())
233
+	action := audit.ActionWebhookActiveSet
234
+	if !newActive {
235
+		action = audit.ActionWebhookActiveUnset
236
+	}
237
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{"webhook_id": hook.ID})
238
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
239
+		action, audit.TargetRepo, row.ID,
240
+		auditMeta)
231
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
241
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
232
 }
242
 }
233
 
243
 
@@ -246,6 +256,11 @@ func (h *Handlers) webhookPing(w http.ResponseWriter, r *http.Request) {
246
 	}, hook.ID); err != nil {
256
 	}, hook.ID); err != nil {
247
 		h.d.Logger.WarnContext(r.Context(), "webhook ping", "error", err)
257
 		h.d.Logger.WarnContext(r.Context(), "webhook ping", "error", err)
248
 	}
258
 	}
259
+	viewer := middleware.CurrentUserFromContext(r.Context())
260
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{"webhook_id": hook.ID})
261
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
262
+		audit.ActionWebhookPinged, audit.TargetRepo, row.ID,
263
+		auditMeta)
249
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
264
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
250
 }
265
 }
251
 
266
 
@@ -310,6 +325,15 @@ func (h *Handlers) webhookRedeliver(w http.ResponseWriter, r *http.Request) {
310
 		http.Error(w, "redeliver failed", http.StatusInternalServerError)
325
 		http.Error(w, "redeliver failed", http.StatusInternalServerError)
311
 		return
326
 		return
312
 	}
327
 	}
328
+	viewer := middleware.CurrentUserFromContext(r.Context())
329
+	auditActor, auditMeta := viewer.AuditActor(map[string]any{
330
+		"webhook_id":           hook.ID,
331
+		"original_delivery_id": originalID,
332
+		"new_delivery_id":      newID,
333
+	})
334
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor,
335
+		audit.ActionWebhookRedelivered, audit.TargetRepo, row.ID,
336
+		auditMeta)
313
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"/deliveries/"+strconv.FormatInt(newID, 10), http.StatusSeeOther)
337
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"/deliveries/"+strconv.FormatInt(newID, 10), http.StatusSeeOther)
314
 }
338
 }
315
 
339