admin api rework (#777)

Co-authored-by: TheUnknownHacker <128781393+The-UnknownHacker@users.noreply.github.com>
This commit is contained in:
Echo 2026-01-08 12:28:21 -05:00 committed by GitHub
parent a031775e64
commit 063403e4a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 625 additions and 10 deletions

View file

@ -19,11 +19,9 @@ module Api
true
else
@admin_api_key.revoke!
render json: { error: "lmao no perms" }, status: :unauthorized
false
end
else
render json: { error: "lmao no perms" }, status: :unauthorized
false
end
end
@ -44,6 +42,12 @@ module Api
def render_forbidden
render json: { error: "lmao no perms" }, status: :forbidden
end
def require_superadmin
unless current_user&.admin_level_superadmin?
render json: { error: "lmao no perms" }, status: :unauthorized
end
end
end
end
end

View file

@ -0,0 +1,76 @@
module Api
module Admin
module V1
class AdminApiKeysController < Api::Admin::V1::ApplicationController
before_action :set_admin_api_key, only: [ :show, :destroy ]
def index
api_keys = AdminApiKey.includes(:user).active.order(created_at: :desc)
render json: {
admin_api_keys: api_keys.map { |key| admin_api_key_json(key) }
}
end
def show
render json: admin_api_key_json(@admin_api_key)
end
def create
admin_api_key = current_user.admin_api_keys.build(name: params[:name])
if admin_api_key.save
render json: {
success: true,
message: "Admin API key created successfully",
admin_api_key: {
id: admin_api_key.id,
name: admin_api_key.name,
token: admin_api_key.token,
created_at: admin_api_key.created_at
}
}, status: :created
else
render json: {
error: "Failed to create admin API key",
errors: admin_api_key.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@admin_api_key.revoke!
render json: {
success: true,
message: "Admin API key has been revoked"
}
end
private
def set_admin_api_key
@admin_api_key = AdminApiKey.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Admin API key not found" }, status: :not_found
end
def admin_api_key_json(key)
{
id: key.id,
name: key.name,
token_preview: "#{key.token[0..10]}...",
user: {
id: key.user.id,
username: key.user.username,
display_name: key.user.display_name,
admin_level: key.user.admin_level
},
created_at: key.created_at,
revoked_at: key.revoked_at,
active: key.active?
}
end
end
end
end
end

View file

@ -0,0 +1,75 @@
module Api
module Admin
module V1
class DeletionRequestsController < Api::Admin::V1::ApplicationController
before_action :require_superadmin
before_action :set_deletion_request, only: [ :show, :approve, :reject ]
def index
pending = DeletionRequest.pending.includes(:user).order(requested_at: :asc)
approved = DeletionRequest.approved.includes(:user, :admin_approved_by).order(scheduled_deletion_at: :asc)
done = DeletionRequest.completed.includes(:user, :admin_approved_by).order(completed_at: :desc).limit(25)
render json: {
pending: pending.map { |dr| deletion_request_json(dr) },
approved: approved.map { |dr| deletion_request_json(dr) },
completed: done.map { |dr| deletion_request_json(dr) }
}
end
def show
render json: deletion_request_json(@deletion_request)
end
def approve
@deletion_request.approve!(current_user)
render json: {
success: true,
message: "Deletion request approved. Scheduled for #{@deletion_request.scheduled_deletion_at.strftime('%B %d, %Y')}",
deletion_request: deletion_request_json(@deletion_request)
}
end
def reject
@deletion_request.cancel!
render json: {
success: true,
message: "Deletion request rejected",
deletion_request: deletion_request_json(@deletion_request)
}
end
private
def set_deletion_request
@deletion_request = DeletionRequest.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Deletion request not found" }, status: :not_found
end
def deletion_request_json(dr)
{
id: dr.id,
user_id: dr.user_id,
user: dr.user ? {
id: dr.user.id,
username: dr.user.username,
display_name: dr.user.display_name
} : nil,
status: dr.status,
requested_at: dr.requested_at,
scheduled_deletion_at: dr.scheduled_deletion_at,
completed_at: dr.completed_at,
admin_approved_by: dr.admin_approved_by ? {
id: dr.admin_approved_by.id,
username: dr.admin_approved_by.username,
display_name: dr.admin_approved_by.display_name
} : nil,
created_at: dr.created_at,
updated_at: dr.updated_at
}
end
end
end
end
end

View file

@ -0,0 +1,68 @@
module Api
module Admin
module V1
class PermissionsController < Api::Admin::V1::ApplicationController
before_action :require_superadmin
def index
users = User.where.not(admin_level: :default)
.order(admin_level: :asc, username: :asc)
if params[:search].present?
user_ids = User.search_identity(params[:search]).pluck(:id)
users = users.where(id: user_ids)
end
users = users.includes(:email_addresses)
render json: {
users: users.map do |user|
{
id: user.id,
username: user.username,
display_name: user.display_name,
slack_username: user.slack_username,
github_username: user.github_username,
admin_level: user.admin_level,
email_addresses: user.email_addresses.map(&:email),
created_at: user.created_at,
updated_at: user.updated_at
}
end
}
end
def update
user = User.find(params[:id])
previous_level = user.admin_level
new_level = params[:admin_level]
unless User.admin_levels.key?(new_level)
return render json: { error: "Invalid admin level" }, status: :unprocessable_entity
end
if user.set_admin_level(new_level)
Rails.logger.info "Admin level changed: User #{user.id} (#{user.display_name}) from #{previous_level} to #{new_level} by #{current_user.display_name}"
render json: {
success: true,
message: "#{user.display_name}'s admin level updated from #{previous_level} to #{new_level}",
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
admin_level: user.admin_level,
previous_admin_level: previous_level,
updated_at: user.updated_at
}
}
else
render json: { error: "Failed to update admin level" }, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
end
end
end
end

View file

@ -0,0 +1,237 @@
module Api
module Admin
module V1
class TimelineController < Api::Admin::V1::ApplicationController
MAX_TIMELINE_USERS = 20
def show
timeout_duration = 10.minutes.to_i
date = params[:date] ? Date.parse(params[:date]) : Time.current.to_date
# User selection logic
raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(",").map(&:to_i).uniq : []
# Handle slack_uids parameter
if params[:slack_uids].present?
slack_uids = params[:slack_uids].split(",").first(MAX_TIMELINE_USERS)
users_from_slack_uids = User.where(slack_uid: slack_uids)
raw_user_ids += users_from_slack_uids.pluck(:id)
end
# Limit total users to prevent DoS
raw_user_ids = raw_user_ids.first(MAX_TIMELINE_USERS)
# Include current user (admin) by default
selected_user_ids = [ current_user.id ] + raw_user_ids
selected_user_ids.uniq!
# Fetch all valid users
users_by_id = User.where(id: selected_user_ids).index_by(&:id)
valid_user_ids = users_by_id.keys
mappings_by_user_project = ProjectRepoMapping.where(user_id: valid_user_ids)
.group_by(&:user_id)
.transform_values { |mappings| mappings.index_by(&:project_name) }
users_to_process = valid_user_ids.map { |id| users_by_id[id] }.compact
# Get heartbeats with expanded time range for timezone differences
server_start_of_day = date.beginning_of_day.to_f
server_end_of_day = date.end_of_day.to_f
expanded_start = server_start_of_day - 24.hours.to_i
expanded_end = server_end_of_day + 24.hours.to_i
all_heartbeats = Heartbeat
.where(user_id: valid_user_ids, deleted_at: nil)
.where("time >= ? AND time <= ?", expanded_start, expanded_end)
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
.order(:user_id, :time)
.to_a
heartbeats_by_user_id = all_heartbeats.group_by(&:user_id)
users_with_timeline_data = []
users_to_process.each do |user|
user_tz = user.timezone || "UTC"
user_start_of_day = date.in_time_zone(user_tz).beginning_of_day.to_f
user_end_of_day = date.in_time_zone(user_tz).end_of_day.to_f
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
.where("time >= ? AND time <= ?", user_start_of_day, user_end_of_day)
total_coded_time_seconds = user_daily_heartbeats_relation.duration_seconds
all_user_heartbeats = heartbeats_by_user_id[user.id] || []
user_heartbeats_for_spans = all_user_heartbeats.select { |hb| hb.time >= user_start_of_day && hb.time <= user_end_of_day }
calculated_spans_with_details = []
if user_heartbeats_for_spans.any?
current_span_heartbeats = []
user_heartbeats_for_spans.each_with_index do |heartbeat, index|
current_span_heartbeats << heartbeat
is_last_heartbeat = (index == user_heartbeats_for_spans.length - 1)
time_to_next = is_last_heartbeat ? Float::INFINITY : (user_heartbeats_for_spans[index + 1].time - heartbeat.time)
if time_to_next > timeout_duration || is_last_heartbeat
if current_span_heartbeats.any?
start_time_numeric = current_span_heartbeats.first.time
last_hb_time_numeric = current_span_heartbeats.last.time
span_duration = last_hb_time_numeric - start_time_numeric
span_duration = 0 if span_duration < 0
files = current_span_heartbeats.map { |h| h.entity&.split("/")&.last }.compact.uniq
projects_edited_details_for_span = []
unique_project_names_in_current_span = current_span_heartbeats.map(&:project).compact.reject(&:blank?).uniq
unique_project_names_in_current_span.each do |p_name|
repo_mapping = mappings_by_user_project.dig(user.id, p_name)
projects_edited_details_for_span << {
name: p_name,
repo_url: repo_mapping&.repo_url
}
end
editors = current_span_heartbeats.map(&:editor).compact.uniq
languages = current_span_heartbeats.map(&:language).compact.uniq
calculated_spans_with_details << {
start_time: start_time_numeric,
end_time: last_hb_time_numeric,
duration: span_duration,
files_edited: files,
projects_edited_details: projects_edited_details_for_span,
editors: editors,
languages: languages
}
current_span_heartbeats = []
end
end
end
end
users_with_timeline_data << {
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
slack_username: user.slack_username,
github_username: user.github_username,
timezone: user.timezone,
avatar_url: user.avatar_url
},
spans: calculated_spans_with_details,
total_coded_time: total_coded_time_seconds
}
end
# Get commit markers
commits_for_timeline = Commit.where(
user_id: selected_user_ids,
created_at: date.beginning_of_day..date.end_of_day
)
timeline_commit_markers = commits_for_timeline.map do |commit|
raw = commit.github_raw || {}
commit_time = if raw.dig("commit", "committer", "date")
Time.parse(raw["commit"]["committer"]["date"])
else
commit.created_at
end
{
user_id: commit.user_id,
timestamp: commit_time.to_f,
additions: (raw["stats"] && raw["stats"]["additions"]) || raw.dig("files", 0, "additions"),
deletions: (raw["stats"] && raw["stats"]["deletions"]) || raw.dig("files", 0, "deletions"),
github_url: raw["html_url"]
}
end
render json: {
date: date.iso8601,
next_date: (date + 1.day).iso8601,
prev_date: (date - 1.day).iso8601,
users: users_with_timeline_data,
commit_markers: timeline_commit_markers
}
rescue Date::Error
render json: { error: "Invalid date format" }, status: :unprocessable_entity
end
def search_users
query_term = params[:query].to_s.downcase
if query_term.blank?
return render json: { error: "Query parameter is required" }, status: :unprocessable_entity
end
user_id_match = nil
if query_term.match?(/^\d+$/)
user_id = query_term.to_i
user_id_match = User.where(id: user_id).first
end
if user_id_match
results = [ {
id: user_id_match.id,
display_name: user_id_match.display_name,
avatar_url: user_id_match.avatar_url
} ]
else
users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR CAST(id AS TEXT) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%")
.order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC"))
.limit(20)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
results = users.map do |user|
{
id: user.id,
display_name: user.display_name,
avatar_url: user.avatar_url
}
end
end
render json: { users: results }
end
def leaderboard_users
period = params[:period]
limit = 25
leaderboard_period_type = (period == "last_7_days") ? :last_7_days : :daily
start_date = Date.current
leaderboard = Leaderboard.where.not(finished_generating_at: nil)
.find_by(start_date: start_date, period_type: leaderboard_period_type, deleted_at: nil)
user_ids_from_leaderboard = leaderboard ? leaderboard.entries.order(total_seconds: :desc).limit(limit).pluck(:user_id) : []
all_ids_to_fetch = user_ids_from_leaderboard.dup
all_ids_to_fetch.unshift(current_user.id).uniq!
users_data = User.where(id: all_ids_to_fetch)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
.index_by(&:id)
final_user_objects = []
# Add admin first
if admin_data = users_data[current_user.id]
final_user_objects << { id: admin_data.id, display_name: admin_data.display_name, avatar_url: admin_data.avatar_url }
end
# Add leaderboard users
user_ids_from_leaderboard.each do |uid|
break if final_user_objects.size >= limit
next if uid == current_user.id
if user_data = users_data[uid]
final_user_objects << { id: user_data.id, display_name: user_data.display_name, avatar_url: user_data.avatar_url }
end
end
render json: { users: final_user_objects }
end
end
end
end
end

View file

@ -0,0 +1,112 @@
module Api
module Admin
module V1
class TrustLevelAuditLogsController < Api::Admin::V1::ApplicationController
def index
audit_logs = TrustLevelAuditLog.includes(:user, :changed_by)
.recent
.limit(250)
# Filter by user_id
if params[:user_id].present?
user = User.find_by(id: params[:user_id])
if user
audit_logs = audit_logs.for_user(user)
else
return render json: { error: "User not found" }, status: :not_found
end
end
# Filter by admin_id (changed_by)
if params[:admin_id].present?
admin = User.find_by(id: params[:admin_id])
if admin
audit_logs = audit_logs.by_admin(admin)
else
return render json: { error: "Admin not found" }, status: :not_found
end
end
# Search by user
if params[:user_search].present?
user_ids = User.search_identity(params[:user_search]).pluck(:id)
audit_logs = audit_logs.where(user_id: user_ids)
end
# Search by admin
if params[:admin_search].present?
admin_ids = User.search_identity(params[:admin_search]).pluck(:id)
audit_logs = audit_logs.where(changed_by_id: admin_ids)
end
# Filter by trust level
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
end
render json: {
audit_logs: audit_logs.map do |log|
{
id: log.id,
user: {
id: log.user.id,
username: log.user.username,
display_name: log.user.display_name
},
previous_trust_level: log.previous_trust_level,
new_trust_level: log.new_trust_level,
changed_by: {
id: log.changed_by.id,
username: log.changed_by.username,
display_name: log.changed_by.display_name,
admin_level: log.changed_by.admin_level
},
reason: log.reason,
notes: log.notes,
created_at: log.created_at
}
end,
total_count: audit_logs.count
}
end
def show
audit_log = TrustLevelAuditLog.find(params[:id])
render json: {
id: audit_log.id,
user: {
id: audit_log.user.id,
username: audit_log.user.username,
display_name: audit_log.user.display_name,
current_trust_level: audit_log.user.trust_level
},
previous_trust_level: audit_log.previous_trust_level,
new_trust_level: audit_log.new_trust_level,
changed_by: {
id: audit_log.changed_by.id,
username: audit_log.changed_by.username,
display_name: audit_log.changed_by.display_name,
admin_level: audit_log.changed_by.admin_level
},
reason: audit_log.reason,
notes: audit_log.notes,
created_at: audit_log.created_at,
updated_at: audit_log.updated_at
}
rescue ActiveRecord::RecordNotFound
render json: { error: "Audit log not found" }, status: :not_found
end
end
end
end
end

View file

@ -116,6 +116,24 @@ class User < ApplicationRecord
has_many :wakatime_mirrors, dependent: :destroy
scope :search_identity, ->(term) {
term = term.to_s.strip.downcase
return none if term.blank?
pattern = "%#{sanitize_sql_like(term)}%"
left_joins(:email_addresses)
.where(
"LOWER(users.username) LIKE :p OR " \
"LOWER(users.slack_username) LIKE :p OR " \
"LOWER(users.github_username) LIKE :p OR " \
"LOWER(email_addresses.email) LIKE :p OR " \
"CAST(users.id AS TEXT) LIKE :p",
p: pattern
)
.distinct
}
has_many :trust_level_audit_logs, dependent: :destroy
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
has_many :deletion_requests, dependent: :destroy

View file

@ -48,6 +48,12 @@ Rails.application.routes.draw do
end
get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user
end
constraints AdminLevelConstraint.new(:superadmin) do
namespace :admin do
resources :permissions, only: [ :index, :update ]
end
end
get "/stop_impersonating", to: "sessions#stop_impersonating", as: :stop_impersonating
if Rails.env.development?
@ -120,12 +126,7 @@ Rails.application.routes.draw do
post "my/settings/rotate_api_key", to: "users#rotate_api_key", as: :my_settings_rotate_api_key
namespace :my do
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
member do
patch :archive
patch :unarchive
end
end
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ }
# resource :mailing_address, only: [ :show, :edit ]
# get "mailroom", to: "mailroom#index"
resources :heartbeats, only: [] do
@ -136,8 +137,6 @@ Rails.application.routes.draw do
end
end
get "deletion", to: "deletion_requests#show", as: :deletion
post "deletion", to: "deletion_requests#create", as: :create_deletion
delete "deletion", to: "deletion_requests#cancel", as: :cancel_deletion
@ -207,6 +206,32 @@ Rails.application.routes.draw do
post "user/get_user_by_email", to: "admin#get_user_by_email"
post "user/search_fuzzy", to: "admin#search_users_fuzzy"
post "user/convict", to: "admin#user_convict"
# Admin API Keys management
resources :admin_api_keys, only: [ :index, :show, :create, :destroy ]
# Trust level audit logs
resources :trust_level_audit_logs, only: [ :index, :show ]
# Deletion requests
resources :deletion_requests, only: [ :index, :show ] do
member do
post :approve
post :reject
end
end
# Permissions management
resources :permissions, only: [ :index ] do
collection do
patch ":id", to: "permissions#update", as: :update
end
end
# Timeline
get "timeline", to: "timeline#show"
get "timeline/search_users", to: "timeline#search_users"
get "timeline/leaderboard_users", to: "timeline#leaderboard_users"
end
end