From 063403e4a03d136a166f6825b698439555a3bf4f Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 8 Jan 2026 12:28:21 -0500 Subject: [PATCH] admin api rework (#777) Co-authored-by: TheUnknownHacker <128781393+The-UnknownHacker@users.noreply.github.com> --- .../api/admin/application_controller.rb | 8 +- .../api/admin/v1/admin_api_keys_controller.rb | 76 ++++++ .../admin/v1/deletion_requests_controller.rb | 75 ++++++ .../api/admin/v1/permissions_controller.rb | 68 +++++ .../api/admin/v1/timeline_controller.rb | 237 ++++++++++++++++++ .../v1/trust_level_audit_logs_controller.rb | 112 +++++++++ app/models/user.rb | 18 ++ config/routes.rb | 41 ++- 8 files changed, 625 insertions(+), 10 deletions(-) create mode 100644 app/controllers/api/admin/v1/admin_api_keys_controller.rb create mode 100644 app/controllers/api/admin/v1/deletion_requests_controller.rb create mode 100644 app/controllers/api/admin/v1/permissions_controller.rb create mode 100644 app/controllers/api/admin/v1/timeline_controller.rb create mode 100644 app/controllers/api/admin/v1/trust_level_audit_logs_controller.rb diff --git a/app/controllers/api/admin/application_controller.rb b/app/controllers/api/admin/application_controller.rb index 0248f9a..6a8bdb3 100644 --- a/app/controllers/api/admin/application_controller.rb +++ b/app/controllers/api/admin/application_controller.rb @@ -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 diff --git a/app/controllers/api/admin/v1/admin_api_keys_controller.rb b/app/controllers/api/admin/v1/admin_api_keys_controller.rb new file mode 100644 index 0000000..a1c57b2 --- /dev/null +++ b/app/controllers/api/admin/v1/admin_api_keys_controller.rb @@ -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 diff --git a/app/controllers/api/admin/v1/deletion_requests_controller.rb b/app/controllers/api/admin/v1/deletion_requests_controller.rb new file mode 100644 index 0000000..cffcd6c --- /dev/null +++ b/app/controllers/api/admin/v1/deletion_requests_controller.rb @@ -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 diff --git a/app/controllers/api/admin/v1/permissions_controller.rb b/app/controllers/api/admin/v1/permissions_controller.rb new file mode 100644 index 0000000..37ee8fb --- /dev/null +++ b/app/controllers/api/admin/v1/permissions_controller.rb @@ -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 diff --git a/app/controllers/api/admin/v1/timeline_controller.rb b/app/controllers/api/admin/v1/timeline_controller.rb new file mode 100644 index 0000000..cdb1cb4 --- /dev/null +++ b/app/controllers/api/admin/v1/timeline_controller.rb @@ -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 diff --git a/app/controllers/api/admin/v1/trust_level_audit_logs_controller.rb b/app/controllers/api/admin/v1/trust_level_audit_logs_controller.rb new file mode 100644 index 0000000..687fdea --- /dev/null +++ b/app/controllers/api/admin/v1/trust_level_audit_logs_controller.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 000f9b4..a27acbc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 5fa743d..250549b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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