forked from wrenn/wrenn
v0.1.3 (#36)
## What's new Compliance, audit, and account lifecycle improvements — admin actions are now fully auditable, user data is properly anonymized on deletion, and OAuth signup flow gives users control over their profile. ### Audit - Added audit logging for all admin actions (user activate/deactivate, team BYOC toggle, team delete, template delete, build create/cancel) - Added admin audit page with infinite scroll and hierarchical filters - Fixed audit log team assignment — admin/host actions now correctly land under PlatformTeamID - Anonymize audit logs on user hard-delete (actor name, IDs, emails stripped) - Deduplicated audit logger internals (665 → 374 lines, no behavior change) ### Authentication - Separated GitHub OAuth login/signup flows — login no longer auto-creates accounts - Added name confirmation dialog for new GitHub signups ### Account Lifecycle - Email notification sent when account is permanently deleted after grace period - Audit log anonymization tied to user purge (per-user transactional) ### UX - Removed accent gradient bars from admin host dialogs (border + shadow only) - Frontend renders deleted users as styled badge in audit log view ### Others - Version bump - Bug fixes Reviewed-on: wrenn/wrenn#36
This commit is contained in:
@ -35,64 +35,38 @@ type auditLogResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List handles GET /v1/audit-logs.
|
||||
// Query params:
|
||||
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
|
||||
// - limit: page size, default 50, max 200
|
||||
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
|
||||
// - action: filter by action verb
|
||||
//
|
||||
// Members see only team-scoped events; admins/owners see all.
|
||||
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
// parseAuditParams extracts common query parameters for audit log listing.
|
||||
func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID, limit int, err error) {
|
||||
limit = 50
|
||||
|
||||
// Parse ?before cursor.
|
||||
var before time.Time
|
||||
if s := r.URL.Query().Get("before"); s != "" {
|
||||
var err error
|
||||
before, err = time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "before must be an RFC3339 timestamp")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ?limit.
|
||||
limit := 50
|
||||
if s := r.URL.Query().Get("limit"); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "limit must be a positive integer")
|
||||
n, parseErr := strconv.Atoi(s)
|
||||
if parseErr != nil || n < 1 {
|
||||
err = parseErr
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
// Parse ?before_id cursor (UUID).
|
||||
var beforeID pgtype.UUID
|
||||
if s := r.URL.Query().Get("before_id"); s != "" {
|
||||
parsed, err := id.ParseAuditLogID(s)
|
||||
beforeID, err = id.ParseAuditLogID(s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "before_id must be a valid audit log ID")
|
||||
return
|
||||
}
|
||||
beforeID = parsed
|
||||
}
|
||||
|
||||
entries, err := h.svc.List(r.Context(), service.AuditListParams{
|
||||
TeamID: ac.TeamID,
|
||||
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
|
||||
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
|
||||
Actions: parseMultiParam(r.URL.Query()["action"]),
|
||||
Before: before,
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// writeAuditResponse serializes audit entries into a paginated JSON response.
|
||||
func writeAuditResponse(w http.ResponseWriter, entries []service.AuditEntry) {
|
||||
items := make([]auditLogResponse, len(entries))
|
||||
for i, e := range entries {
|
||||
items[i] = auditLogResponse{
|
||||
@ -120,6 +94,67 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// List handles GET /v1/audit-logs.
|
||||
// Query params:
|
||||
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
|
||||
// - limit: page size, default 50, max 200
|
||||
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
|
||||
// - action: filter by action verb
|
||||
//
|
||||
// Members see only team-scoped events; admins/owners see all.
|
||||
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
|
||||
before, beforeID, limit, err := parseAuditParams(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.List(r.Context(), service.AuditListParams{
|
||||
TeamID: ac.TeamID,
|
||||
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
|
||||
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
|
||||
Actions: parseMultiParam(r.URL.Query()["action"]),
|
||||
Before: before,
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
||||
return
|
||||
}
|
||||
|
||||
writeAuditResponse(w, entries)
|
||||
}
|
||||
|
||||
// AdminList handles GET /v1/admin/audit-logs.
|
||||
// Returns audit logs for the platform team (team 0) with both team and admin scopes.
|
||||
// Uses the same query params as List.
|
||||
func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
|
||||
before, beforeID, limit, err := parseAuditParams(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.svc.List(r.Context(), service.AuditListParams{
|
||||
TeamID: id.PlatformTeamID,
|
||||
AdminScoped: true,
|
||||
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
|
||||
Actions: parseMultiParam(r.URL.Query()["action"]),
|
||||
Before: before,
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
|
||||
return
|
||||
}
|
||||
|
||||
writeAuditResponse(w, entries)
|
||||
}
|
||||
|
||||
// parseMultiParam flattens repeated params and comma-separated values into a
|
||||
// single deduplicated slice. Empty strings are dropped. Returns nil (no filter)
|
||||
// when no values are present.
|
||||
|
||||
Reference in New Issue
Block a user