audit logs because neon messed it up

This commit is contained in:
Echo 2025-06-30 22:52:29 -04:00
parent 7bd2f0b18d
commit 1dcba5fb10
No known key found for this signature in database
12 changed files with 552 additions and 8 deletions

View file

@ -0,0 +1,73 @@
class Admin::TrustLevelAuditLogsController < Admin::BaseController
before_action :require_admin
def index
@audit_logs = TrustLevelAuditLog.includes(:user, :changed_by)
.recent
.limit(100) # if there are more actions, fuck off man
if params[:user_id].present?
user = User.find_by(id: params[:user_id])
if user
@audit_logs = @audit_logs.for_user(user)
@filtered_user = user
end
end
if params[:admin_id].present?
admin = User.find_by(id: params[:admin_id])
if admin
@audit_logs = @audit_logs.by_admin(admin)
@filtered_admin = admin
end
end
if params[:user_search].present?
search_term = params[:user_search].strip
user_ids = User.joins(:email_addresses)
.where("LOWER(users.username) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?",
"%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term}%")
.pluck(:id)
@audit_logs = @audit_logs.where(user_id: user_ids)
@user_search = search_term
end
if params[:admin_search].present?
search_term = params[:admin_search].strip
admin_ids = User.joins(:email_addresses)
.where("LOWER(users.username) LIKE ? OR LOWER(users.slack_username) LIKE ? OR LOWER(users.github_username) LIKE ? OR LOWER(email_addresses.email) LIKE ? OR CAST(users.id AS TEXT) LIKE ?",
"%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term.downcase}%", "%#{search_term}%")
.pluck(:id)
@audit_logs = @audit_logs.where(changed_by_id: admin_ids)
@admin_search = search_term
end
if params[:trust_level_filter].present? && params[:trust_level_filter] != "all"
case params[:trust_level_filter]
when "to_convicted"
@audit_logs = @audit_logs.where(new_trust_level: "red")
when "to_trusted"
@audit_logs = @audit_logs.where(new_trust_level: "green")
when "to_suspected"
@audit_logs = @audit_logs.where(new_trust_level: "yellow")
when "to_unscored"
@audit_logs = @audit_logs.where(new_trust_level: "blue")
end
@trust_level_filter = params[:trust_level_filter]
end
@audit_logs = @audit_logs.to_a
end
def show
@audit_log = TrustLevelAuditLog.find(params[:id])
end
private
def require_admin
unless current_user&.admin?
redirect_to root_path, alert: "no perms lmaooo"
end
end
end

View file

@ -78,10 +78,32 @@ class UsersController < ApplicationController
@user = User.find(params[:id])
require_admin
if @user && current_user.admin? && params[:trust_level].present?
if User.trust_levels.key?(params[:trust_level])
@user.set_trust(params[:trust_level])
render json: { success: true, message: "updated", trust_level: @user.trust_level }
trust_level = params[:trust_level]
reason = params[:reason]
notes = params[:notes]
if @user && current_user.admin? && trust_level.present?
unless User.trust_levels.key?(trust_level)
return render json: { error: "you fucked it up lmaooo" }, status: :unprocessable_entity
end
if trust_level == "red" && !current_user.can_convict_users?
return render json: { error: "no perms lmaooo" }, status: :forbidden
end
success = @user.set_trust(
trust_level,
changed_by_user: current_user,
reason: reason,
notes: notes
)
if success
render json: {
success: true,
message: "updated",
trust_level: @user.trust_level
}
else
render json: { error: "402 invalid" }, status: :unprocessable_entity
end

View file

@ -0,0 +1,35 @@
class TrustLevelAuditLog < ApplicationRecord
belongs_to :user
belongs_to :changed_by, class_name: "User"
validates :previous_trust_level, presence: true
validates :new_trust_level, presence: true
validates :user_id, presence: true
validates :changed_by_id, presence: true
enum :previous_trust_level, {
blue: "blue",
red: "red",
green: "green",
yellow: "yellow"
}, prefix: :previous
enum :new_trust_level, {
blue: "blue",
red: "red",
green: "green",
yellow: "yellow"
}, prefix: :new
scope :recent, -> { order(created_at: :desc) }
scope :for_user, ->(user) { where(user: user) }
scope :by_admin, ->(admin) { where(changed_by: admin) }
def trust_level_change_description
"#{previous_trust_level.capitalize}#{new_trust_level.capitalize}"
end
def admin_name
changed_by.display_name
end
end

View file

@ -27,8 +27,30 @@ class User < ApplicationRecord
yellow: 3 # suspected (invisible to user)
}
def set_trust(level)
update!(trust_level: level)
def set_trust(level, changed_by_user: nil, reason: nil, notes: nil)
return false unless level.present?
previous_level = trust_level
if changed_by_user.present? && level.to_s == "red" && !changed_by_user.superadmin?
return false
end
if previous_level != level.to_s
if changed_by_user.present?
trust_level_audit_logs.create!(
changed_by: changed_by_user,
previous_trust_level: previous_level,
new_trust_level: level.to_s,
reason: reason,
notes: notes
)
end
update!(trust_level: level)
end
true
end
# ex: .set_trust(:green) or set_trust(1) setting it to red
@ -59,6 +81,9 @@ class User < ApplicationRecord
has_many :wakatime_mirrors, dependent: :destroy
has_many :trust_level_audit_logs, dependent: :destroy
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
def streak_days
@streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
end
@ -148,17 +173,37 @@ class User < ApplicationRecord
end
def admin?
is_admin
is_admin || is_superadmin
end
def superadmin?
is_superadmin
end
def make_admin!
update!(is_admin: true)
end
def make_superadmin!
update!(is_superadmin: true, is_admin: true)
end
def remove_admin!
update!(is_admin: false)
end
def remove_superadmin!
update!(is_superadmin: false)
end
def can_convict_users?
superadmin?
end
def can_moderate_trust_levels?
admin?
end
def raw_github_user_info
return nil unless github_uid.present?
return nil unless github_access_token.present?

View file

@ -0,0 +1,193 @@
<% content_for :title, "admin aboose logs" %>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">admin aboose logs</h1>
<p class="text-gray-400">look at all the funky shit that admins do</p>
</div>
<div class="mb-6 bg-dark rounded-lg p-6">
<%= form_with url: admin_trust_level_audit_logs_path, method: :get, local: true, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= form.label :user_search, "user lookup", class: "block text-sm font-medium text-gray-300 mb-2" %>
<%= form.text_field :user_search,
value: @user_search,
placeholder: "just put anything here or something",
class: "w-full px-3 py-2 bg-darkless rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :admin_search, "admin lookup", class: "block text-sm font-medium text-gray-300 mb-2" %>
<%= form.text_field :admin_search,
value: @admin_search,
placeholder: "just put anything here or something",
class: "w-full px-3 py-2 bg-darkless rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" %>
</div>
<div>
<%= form.label :trust_level_filter, "filter by trust updates", class: "block text-sm font-medium text-gray-300 mb-2" %>
<%= form.select :trust_level_filter,
options_for_select([
['All Changes', 'all'],
['🔴 Set to Convicted', 'to_convicted'],
['🟢 Set to Trusted', 'to_trusted'],
['🟡 Set to Suspected', 'to_suspected'],
['🔵 Set to Unscored', 'to_unscored']
], @trust_level_filter || 'all'),
{},
{ class: "w-full px-3 py-2 bg-darkless rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" } %>
</div>
</div>
<div class="flex items-center gap-4">
<%= form.submit "run that shit", class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium transition-colors" %>
<%= link_to "alt+f4", admin_trust_level_audit_logs_path, class: "bg-gray-600 hover:bg-darkless text-white px-4 py-2 rounded-md font-medium transition-colors" %>
<span class="text-md text-gray-400">
found <%= @audit_logs.length %> result<%= @audit_logs.length == 1 ? '' : 's' %>
</span>
</div>
<% end %>
</div>
<% if @filtered_user || @filtered_admin %>
<div class="mb-6 p-4 bg-dark border border-blue-500/30 rounded-lg">
<div class="flex items-center justify-between">
<div>
<% if @filtered_user %>
<p class="text-sm text-blue-300">
filtering logs by <strong><%= @filtered_user.display_name %></strong>
</p>
<% end %>
<% if @filtered_admin %>
<p class="text-sm text-blue-300">
filtering logs by admin: <strong><%= @filtered_admin.display_name %></strong>
</p>
<% end %>
</div>
<%= link_to "fuckin abort",
admin_trust_level_audit_logs_path,
class: "text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded" %>
</div>
</div>
<% end %>
<div class="bg-dark rounded-lg overflow-hidden shadow-xl">
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="">
<tr>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
time
</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
goober
</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
change
</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
goobed by
</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
why
</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-300">
link
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-950">
<% @audit_logs.each do |log| %>
<tr class="hover:bg-darkless">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
<%= log.created_at.strftime("%b %d, %Y at %I:%M %p") %>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<% if log.user.avatar_url %>
<img class="h-8 w-8 rounded-full mr-3" src="<%= log.user.avatar_url %>" alt="">
<% end %>
<div>
<div class="text-sm font-medium text-white">
<%= log.user.display_name %>
</div>
<div class="text-sm text-gray-400">
ID: <%= log.user.id %>
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span class="text-sm text-gray-300">
<%
previous_emoji = case log.previous_trust_level
when "blue" then "🔵"
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
end
new_emoji = case log.new_trust_level
when "blue" then "🔵"
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
end
%>
<%= previous_emoji %> <strong><%= log.previous_trust_level.capitalize %></strong>
<%= new_emoji %> <strong><%= log.new_trust_level.capitalize %></strong>
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<% if log.changed_by.avatar_url %>
<img class="h-6 w-6 rounded-full mr-2" src="<%= log.changed_by.avatar_url %>" alt="">
<% end %>
<div>
<div class="text-sm text-white">
<%= log.changed_by.display_name %>
<% if log.changed_by.superadmin? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
supa admin
</span>
<% elsif log.changed_by.admin? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
admin
</span>
<% end %>
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-300">
<% if log.reason.present? %>
<div class="max-w-xs truncate" title="<%= log.reason %>">
<%= log.reason %>
</div>
<% else %>
<span class="text-gray-500 italic">plead the 5th</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<%= link_to "the deets",
admin_trust_level_audit_log_path(log),
class: "text-blue-400 hover:text-blue-300" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% if @audit_logs.empty? %>
<div class="text-center py-12">
<div class="text-gray-400 text-lg mb-2">theres nothin</div>
<p class="text-gray-500">new shit will be seen here</p>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,124 @@
<% content_for :title, "title" %>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-white">looking at a single audit log</h1>
<%= link_to "get me outta here",
admin_trust_level_audit_logs_path,
class: "bg-darkless text-white px-4 py-2 rounded-lg" %>
</div>
</div>
<div class="bg-darkless rounded-lg shadow-xl p-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h2 class="text-xl font-semibold text-white mb-4">user</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<%= render "shared/user_mention", user: @audit_log.user %>
<div class="text-sm text-gray-400">
id: <%= @audit_log.user.id %>
</div>
</div>
<div class="pt-4">
<%= link_to "actions on this goober",
admin_trust_level_audit_logs_path(user_id: @audit_log.user.id),
class: "text-blue-400 hover:text-blue-300" %>
</div>
</div>
</div>
<div>
<h2 class="text-xl font-semibold text-white mb-4">updated by</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<%= render "shared/user_mention", user: @audit_log.changed_by %>
<% if @audit_log.changed_by.superadmin? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 ml-2">
supa admin
</span>
<% elsif @audit_log.changed_by.admin? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 ml-2">
admin
</span>
<% end %>
</div>
<div class="text-sm text-gray-400">
id: <%= @audit_log.changed_by.id %>
</div>
</div>
<div class="pt-4">
<%= link_to "changes by this goober",
admin_trust_level_audit_logs_path(admin_id: @audit_log.changed_by.id),
class: "text-blue-400 hover:text-blue-300" %>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h2 class="text-xl font-semibold text-white mb-4">the deets</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">executed at</label>
<div class="text-white">
<%= @audit_log.created_at.strftime("%B %d, %Y at %I:%M %p %Z") %>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">before</label>
<div class="text-white text-lg">
<%
a = case @audit_log.previous_trust_level
when "blue" then "🔵"
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
end
%>
<%= a %> <strong><%= @audit_log.previous_trust_level.capitalize %></strong>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">after</label>
<div class="text-white text-lg">
<%
b = case @audit_log.new_trust_level
when "blue" then "🔵"
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
end
%>
<%= b %> <strong><%= @audit_log.new_trust_level.capitalize %></strong>
</div>
</div>
</div>
<% if @audit_log.reason.present? %>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Reason</label>
<div class="text-white bg-gray-800 p-4 rounded-lg">
<%= simple_format(@audit_log.reason) %>
</div>
</div>
<% end %>
<% if @audit_log.notes.present? %>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Additional Notes</label>
<div class="text-white bg-gray-800 p-4 rounded-lg">
<%= simple_format(@audit_log.notes) %>
</div>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -39,6 +39,11 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% if current_user %>
<meta name="user-is-superadmin" content="<%= current_user.superadmin? %>">
<meta name="user-is-admin" content="<%= current_user.admin? %>">
<% end %>
<%= yield :head %>

View file

@ -108,6 +108,11 @@
Feature Flags
<% end %>
<% end %>
<% admin_tool(nil, "div") do %>
<%= link_to admin_trust_level_audit_logs_path, class: "block px-2 py-1 rounded-lg transition #{current_page?(admin_trust_level_audit_logs_path) || request.path.start_with?('/admin/trust_level_audit_logs') ? 'bg-primary/50 text-primary' : 'hover:bg-[#23272a]'}", data: { action: "click->nav#clickLink" } do %>
Trust Level Logs
<% end %>
<% end %>
</div>
</div>
</div>

View file

@ -29,6 +29,8 @@ Rails.application.routes.draw do
get "post_reviews/:post_id/date/:date", to: "post_reviews#show", as: :post_review_on_date
get "ysws_reviews/:record_id", to: "ysws_reviews#show", as: :ysws_review
resources :trust_level_audit_logs, only: [ :index, :show ]
end
if Rails.env.development?

View file

@ -0,0 +1,5 @@
class AddIsSuperadminToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :is_superadmin, :boolean, default: false, null: false
end
end

View file

@ -0,0 +1,17 @@
class CreateTrustLevelAuditLogs < ActiveRecord::Migration[8.0]
def change
create_table :trust_level_audit_logs do |t|
t.references :user, null: false, foreign_key: true, index: true
t.references :changed_by, null: false, foreign_key: { to_table: :users }, index: true
t.string :previous_trust_level, null: false
t.string :new_trust_level, null: false
t.text :reason, null: true
t.text :notes, null: true
t.timestamps null: false
end
add_index :trust_level_audit_logs, [ :user_id, :created_at ], name: 'index_trust_level_audit_logs_on_user_and_created_at'
add_index :trust_level_audit_logs, [ :changed_by_id, :created_at ], name: 'index_trust_level_audit_logs_on_changed_by_and_created_at'
end
end

20
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_28_011017) do
ActiveRecord::Schema[8.0].define(version: 2025_06_30_000002) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
@ -483,6 +483,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_28_011017) do
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
create_table "trust_level_audit_logs", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "changed_by_id", null: false
t.string "previous_trust_level", null: false
t.string "new_trust_level", null: false
t.text "reason"
t.text "notes"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["changed_by_id", "created_at"], name: "index_trust_level_audit_logs_on_changed_by_and_created_at"
t.index ["changed_by_id"], name: "index_trust_level_audit_logs_on_changed_by_id"
t.index ["user_id", "created_at"], name: "index_trust_level_audit_logs_on_user_and_created_at"
t.index ["user_id"], name: "index_trust_level_audit_logs_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "slack_uid"
t.datetime "created_at", null: false
@ -506,6 +521,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_28_011017) do
t.string "mailing_address_otc"
t.boolean "allow_public_stats_lookup", default: true, null: false
t.boolean "default_timezone_leaderboard", default: true, null: false
t.boolean "is_superadmin", default: false, null: false
t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token"
t.index ["github_uid"], name: "index_users_on_github_uid"
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
@ -552,5 +568,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_28_011017) do
add_foreign_key "project_repo_mappings", "users"
add_foreign_key "repo_host_events", "users"
add_foreign_key "sign_in_tokens", "users"
add_foreign_key "trust_level_audit_logs", "users"
add_foreign_key "trust_level_audit_logs", "users", column: "changed_by_id"
add_foreign_key "wakatime_mirrors", "users"
end