Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
56 changes: 26 additions & 30 deletions 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 All @@ -29,35 +33,6 @@
>
</div>
<div class="grid grid-cols-1 gap-8">
<div class="bg-dark rounded-lg p-6">
<h2 class="text-xl font-semibold text-surface-content mb-4">
okay now what lmao
</h2>
<div class="space-y-4">
<DetailField label="what was it again?" variant="mutedMedium">
<code
class="block bg-darkless px-3 py-2 rounded text-surface-content text-sm"
>
{api_key.token_preview}
</code>{#if !show_token}<p class="text-md text-muted mt-1">
you cant see the full thing again, we showed it when you created
it ya doofus
</p>{/if}
</DetailField>
<DetailField label="how to use it?" variant="heading">
<p class="text-md text-muted mb-2">
most likely you are not crazy enough to build your own api client
but you can use rowan's fraud check tool to use the admin api.
</p>
<p class="text-md text-muted">
if you are building your own client, just use the token as a bearer
token in the auth header of your requests, check the actual source
code for more details. or you could just be normal and use the fraud
check tool i already made for you.
</p>
</DetailField>
</div>
</div>
<div class="bg-dark rounded-lg p-6">
<h2 class="text-xl font-semibold text-surface-content mb-4">deets</h2>
{#if show_token}<div
Expand Down Expand Up @@ -100,7 +75,28 @@
<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>
<DetailField
label="token preview"
variant="muted"
class="md:col-span-2"
>
<code class="text-sm text-surface-content"
>{api_key.token_preview}</code
>
</DetailField>
</div>
<div class="mt-6"></div>
Expand Down
Loading
Loading