Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/controllers/admin/admin_api_keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,16 @@ def destroy
def inertia_layout_props = super.merge(full_width: true)

def render_new(status: :ok)
render inertia: "Admin/AdminApiKeys/New", props: { errors: @admin_api_key.errors.full_messages }, status: status
render inertia: "Admin/AdminApiKeys/New", props: {
errors: @admin_api_key.errors.full_messages,
available_scopes: AdminApiKey::SCOPES.map { |scope, description| { scope:, description: } },
selected_scopes: @admin_api_key.scopes
}, status: status
end

def serialize_key(key, created_at:, token: nil)
{ id: key.id, name: key.name, token_preview: "#{key.token_preview}...", token: token, created_at: created_at,
active: key.active?, scopes: key.scopes, all_scopes: (AdminApiKey::SCOPES.keys - key.scopes).empty?, expires_at: key.expires_at&.in_time_zone&.strftime("%b %d, %Y at %I:%M %p %Z"),
last_used_at: key.last_used_at&.in_time_zone&.strftime("%b %d, %Y at %I:%M %p %Z"),
user: { id: key.user.id, display_name: key.user.display_name, avatar_url: key.user.avatar_url } }
end
Expand All @@ -71,6 +76,6 @@ def set_own_admin_api_key
end

def admin_api_key_params
params.require(:admin_api_key).permit(:name)
params.require(:admin_api_key).permit(:name, scopes: [])
end
end
22 changes: 22 additions & 0 deletions app/controllers/api/admin/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,33 @@ class ApplicationController < ActionController::API

ADMIN_API_LEVELS = AdminApiKeyAuthentication::ADMIN_API_LEVELS

# Action name (or "*" for the whole controller) => AdminApiKey scope.
# Actions without a scope reject admin API keys.
class_attribute :admin_api_key_scopes, instance_accessor: false, default: {}.freeze

before_action :authenticate_admin!
before_action :authorize_admin_api_key_scope!
before_action :set_paper_trail_whodunnit

def self.require_admin_api_key_scope(scope, only: "*")
self.admin_api_key_scopes = admin_api_key_scopes.merge(Array(only).to_h { |action| [ action.to_s, scope ] }).freeze
end

def self.admin_api_key_scope_for(action)
admin_api_key_scopes[action.to_s] || admin_api_key_scopes["*"]
end

private

def authorize_admin_api_key_scope!
return unless current_admin_api_key

scope = self.class.admin_api_key_scope_for(action_name)
return if scope && current_admin_api_key.scope?(scope)

render_forbidden(scope ? "This admin API key is missing the #{scope} scope" : "Admin API keys can't use this endpoint")
end

def authenticate_admin!
authenticate_or_request_with_http_token do |token, _|
auth_admin_api_key(token) || auth_oauth_admin(token)
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/api/admin/v1/admin_api_keys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Api
module Admin
module V1
class AdminApiKeysController < Api::Admin::V1::ApplicationController
require_admin_api_key_scope "admin_api_keys:read", only: %i[index show]
require_admin_api_key_scope "admin_api_keys:write", only: %i[create destroy]
before_action :set_admin_api_key, only: [ :show ]
before_action :set_own_admin_api_key, only: [ :destroy ]

Expand All @@ -18,7 +20,12 @@ def show
end

def create
admin_api_key = current_user.admin_api_keys.build(name: params[:name])
scopes = Array(params[:scopes])
if current_admin_api_key && (scopes - current_admin_api_key.scopes).any?
return render_forbidden("An admin API key can only create keys with scopes it has itself")
end

admin_api_key = current_user.admin_api_keys.build(name: params[:name], scopes: scopes)

if admin_api_key.save
render json: {
Expand All @@ -28,6 +35,7 @@ def create
id: admin_api_key.id,
name: admin_api_key.name,
token: admin_api_key.token,
scopes: admin_api_key.scopes,
created_at: admin_api_key.created_at
}
}, status: :created
Expand Down Expand Up @@ -73,7 +81,9 @@ def admin_api_key_json(key)
display_name: key.user.display_name,
admin_level: key.user.admin_level
},
scopes: key.scopes,
created_at: key.created_at,
expires_at: key.expires_at,
revoked_at: key.revoked_at,
active: key.active?
}
Expand Down
6 changes: 5 additions & 1 deletion app/controllers/api/admin/v1/admin_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module V1
class AdminController < Api::Admin::V1::ApplicationController
include Api::Admin::V1::UserUtilities

skip_before_action :authorize_admin_api_key_scope!, only: :check
require_admin_api_key_scope "users:read", only: %i[banned_users active_users audit_logs_counts]
require_admin_api_key_scope "heartbeats:read", only: %i[visualization_quantized alt_candidates heartbeats_by_user_agent_segment]

def check
u = current_user
body = {
Expand All @@ -18,7 +22,7 @@ def check

if (k = current_admin_api_key)
body[:auth] = { type: "api_key" }
body[:api_key] = { id: k.id, name: k.name, created_at: k.created_at }
body[:api_key] = { id: k.id, name: k.name, scopes: k.scopes, created_at: k.created_at, expires_at: k.expires_at }
elsif (t = current_oauth_token)
a = t.application
body[:auth] = {
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/admin/v1/bans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module V1
class BansController < Api::Admin::V1::ApplicationController
MAX_RAW_DATE_LENGTH = 40

require_admin_api_key_scope "bans:read", only: :show
require_admin_api_key_scope "bans:write", only: %i[create destroy]
before_action :require_poison_permission
before_action :set_user

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/admin/v1/deletion_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Api
module Admin
module V1
class DeletionRequestsController < Api::Admin::V1::ApplicationController
require_admin_api_key_scope "deletion_requests:read", only: %i[index show]
require_admin_api_key_scope "deletion_requests:write", only: %i[approve reject]
before_action :require_superadmin
before_action :set_deletion_request, only: [ :show, :approve, :reject ]

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/admin/v1/heartbeats_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Api
module Admin
module V1
class HeartbeatsController < Api::Admin::V1::ApplicationController
require_admin_api_key_scope "heartbeats:read"

MAX_LIMIT = 10_000
DEFAULT_LIMIT = 1_000

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Api
module Admin
module V1
class LeaderboardShadowbansController < Api::Admin::V1::ApplicationController
require_admin_api_key_scope "leaderboard_shadowbans:read", only: %i[index search_users]
require_admin_api_key_scope "leaderboard_shadowbans:write", only: %i[create destroy]
before_action :require_shadowban_admin!

def index
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/admin/v1/permissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module V1
class PermissionsController < Api::Admin::V1::ApplicationController
include AdminLevelChangeMessages

require_admin_api_key_scope "permissions:read", only: :index
require_admin_api_key_scope "permissions:write", only: :update
before_action :require_superadmin

def index
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/admin/v1/timeline_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module Api
module Admin
module V1
class TimelineController < Api::Admin::V1::ApplicationController
require_admin_api_key_scope "heartbeats:read"

MAX_TIMELINE_USERS = 20

def show
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class TrustLevelAuditLogsController < Api::Admin::V1::ApplicationController
"to_unscored" => "blue"
}.freeze

require_admin_api_key_scope "users:read", only: %i[index show]
require_admin_api_key_scope "users:write", only: :update
before_action :can_write!, only: [ :update ]

def index
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/stats_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class Api::V1::StatsController < ApplicationController
USER_LOOKUP_ACTIONS = [ :user_stats, :user_spans, :user_projects, :user_project, :user_projects_details ].freeze

before_action :authenticate_admin_api_key!, only: [ :show ], unless: -> { Rails.env.development? }
before_action -> { authenticate_admin_api_key!(scope: "users:read") }, only: [ :show ], unless: -> { Rails.env.development? }
before_action :set_user, only: USER_LOOKUP_ACTIONS
before_action :ensure_public_stats_allowed!, only: USER_LOOKUP_ACTIONS

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Api::V1::UsersController < ApplicationController
before_action :authenticate_admin_api_key!, unless: -> { Rails.env.development? }
before_action -> { authenticate_admin_api_key!(scope: "users:read") }, unless: -> { Rails.env.development? }

def lookup_email
user = EmailAddress.find_by(email: params[:email])&.user
Expand Down
7 changes: 4 additions & 3 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,13 @@ def authenticate_user!
end
end

def authenticate_admin_api_key!(message: "Unauthorized")
def authenticate_admin_api_key!(scope:, message: "Unauthorized")
authorization = request.headers["Authorization"]
if authorization&.match?(/\ABearer +\S+\z/i)
scheme, header_token = authorization_credentials
if bearer_scheme?(scheme)
return if auth_admin_api_key(header_token)
if bearer_scheme?(scheme) && auth_admin_api_key(header_token)
return if @admin_api_key.scope?(scope)
return render_forbidden("This admin API key is missing the #{scope} scope")
end
end

Expand Down
3 changes: 3 additions & 0 deletions app/controllers/concerns/api/admin/v1/user_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ module UserUtilities
}.freeze

included do
require_admin_api_key_scope "users:read", only: %i[get_user_by_email search_users_fuzzy user_info user_info_batch user_projects trust_logs]
require_admin_api_key_scope "heartbeats:read", only: %i[get_users_by_ip get_users_by_machine user_stats user_heartbeats user_heartbeat_values]
require_admin_api_key_scope "users:write", only: :user_convict
before_action :can_write!, only: [ :user_convict ]
end

Expand Down
9 changes: 8 additions & 1 deletion app/javascript/pages/Admin/AdminApiKeys/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
token_preview: string;
created_at: string;
last_used_at: string | null;
scopes: string[];
all_scopes: boolean;
expires_at: string | null;
user: { id: number; display_name: string; avatar_url: string | null };
};
let {
Expand Down Expand Up @@ -47,7 +50,7 @@
<table class="min-w-full">
<thead
><tr class="bg-darkless"
>{#each ["name", "spawned by", "spawned at", "last used", "token", "perform"] as heading}<th
>{#each ["name", "spawned by", "spawned at", "last used", "scopes", "expires", "token", "perform"] as heading}<th
class="px-6 py-3 text-left text-sm font-medium text-muted"
>{heading}</th
>{/each}</tr
Expand Down Expand Up @@ -76,6 +79,10 @@
>{key.created_at}</td
><td class="px-6 py-4 whitespace-nowrap text-sm text-muted"
>{key.last_used_at ?? "No use recorded"}</td
><td class="px-6 py-4 max-w-xs text-xs text-muted"
>{key.all_scopes ? "all scopes" : key.scopes.join(", ")}</td
><td class="px-6 py-4 whitespace-nowrap text-sm text-muted"
>{key.expires_at ?? "never"}</td
><td class="px-6 py-4 whitespace-nowrap"
><code class="text-xs text-surface-content">
{key.token_preview}
Expand Down
41 changes: 40 additions & 1 deletion app/javascript/pages/Admin/AdminApiKeys/New.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
import Button from "../../../components/Button.svelte";
import TextInput from "../../../components/TextInput.svelte";
import { adminAdminApiKeys } from "../../../api";
let { errors }: { errors: string[] } = $props();
let {
errors,
available_scopes,
selected_scopes,
}: {
errors: string[];
available_scopes: { scope: string; description: string }[];
selected_scopes: string[];
} = $props();
</script>

<svelte:head><title>New admin API key · Admin | Hackatime</title></svelte:head>
Expand Down Expand Up @@ -47,6 +55,37 @@
class="w-full px-3 py-2 bg-darkless rounded text-surface-content focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<fieldset>
<legend class="block text-md font-medium text-surface-content mb-1">
Scopes
</legend>
<p class="text-sm text-muted mb-3">
only tick what you actually need. the key still cant do anything your
admin level cant
</p>
<input type="hidden" name="admin_api_key[scopes][]" value="" />
<div class="space-y-2">
{#each available_scopes as { scope, description }}
<label
for={`scope_${scope}`}
class="flex items-start gap-3 rounded-lg bg-darkless p-3 cursor-pointer"
>
<input
id={`scope_${scope}`}
type="checkbox"
name="admin_api_key[scopes][]"
value={scope}
checked={selected_scopes.includes(scope)}
class="mt-1 h-4 w-4 rounded border-surface-300 bg-darker text-primary"
/>
<span>
<code class="text-sm text-surface-content">{scope}</code>
<span class="mt-1 block text-xs text-muted">{description}</span>
</span>
</label>
{/each}
</div>
</fieldset>
<div class="bg-yellow/30 border border-yellow/50 rounded-lg p-4">
<h3 class="text-yellow font-medium mb-2">
keep in mind this gives admin access, so dont go sharing it with your
Expand Down
18 changes: 17 additions & 1 deletion app/javascript/pages/Admin/AdminApiKeys/Show.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
token_preview: string;
token: string | null;
created_at: string;
active: boolean;
scopes: string[];
all_scopes: boolean;
expires_at: string | null;
user: { id: number; display_name: string; avatar_url: string | null };
};
let { api_key, show_token }: { api_key: Key; show_token: boolean } = $props();
Expand Down Expand Up @@ -100,7 +104,19 @@
<div class="text-surface-content">{api_key.created_at}</div>
</DetailField>
<DetailField label="status" variant="muted">
<div class="text-green">live</div>
{#if !api_key.active}<div class="text-red">
dead
</div>{:else if api_key.expires_at}<div class="text-yellow">
live until {api_key.expires_at}, then it gets revoked
</div>{:else}<div class="text-green">live</div>{/if}
</DetailField>
<DetailField label="scopes" variant="muted" class="md:col-span-2">
<div class="flex flex-wrap gap-2">
{#each api_key.scopes as scope}<code
class="bg-darkless px-2 py-1 rounded text-xs text-surface-content"
>{scope}</code
>{/each}
</div>
</DetailField>
</div>
<div class="mt-6"></div>
Expand Down
23 changes: 23 additions & 0 deletions app/jobs/admin_api_key_expiry_notice_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Run by hand after deploy. Each key is only ever included in one email.
class AdminApiKeyExpiryNoticeJob < ApplicationJob
include GoodJob::ActiveJobExtensions::Concurrency

queue_as :literally_whenever
good_job_control_concurrency_with(total_limit: 1)

def perform
AdminApiKey.active.where.not(expires_at: nil).where(expiry_notice_sent_at: nil)
.joins(:user).where(users: { admin_level: AdminApiKeyAuthentication::ADMIN_API_LEVELS })
.includes(:user).group_by(&:user).each do |user, keys|
recipient_email = user.email_addresses.order(:id).pick(:email)
next if recipient_email.blank?

# Marking the keys and enqueueing the mail commit together, and the mail
# delivery job retries on its own, so a retry of this job never resends.
AdminApiKey.transaction do
AdminApiKey.where(id: keys.map(&:id)).update_all(expiry_notice_sent_at: Time.current)
AdminApiKeyMailer.expiry_notice(user, keys, recipient_email:).deliver_later
end
end
end
end
7 changes: 7 additions & 0 deletions app/jobs/revoke_expired_admin_api_keys_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class RevokeExpiredAdminApiKeysJob < ApplicationJob
queue_as :literally_whenever

def perform
AdminApiKey.where(revoked_at: nil, expires_at: ..Time.current).find_each(&:revoke!)
Comment thread
skyfallwastaken marked this conversation as resolved.
Resolved
end
end
12 changes: 12 additions & 0 deletions app/mailers/admin_api_key_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class AdminApiKeyMailer < ApplicationMailer
def expiry_notice(user, keys, recipient_email:)
@user = user
@zone = ActiveSupport::TimeZone[user.timezone] || Time.find_zone!("UTC")
@keys = keys.sort_by(&:expires_at)

mail(
to: recipient_email,
subject: "Your Hackatime admin API #{"key".pluralize(@keys.size)} will be revoked on #{@keys.first.expires_at.in_time_zone(@zone).strftime("%-d %B %Y")}"
)
end
end
Loading
Loading