admin: per-area permissions on every route

Give each user independent read, write and delete flags per area
(invoices, bingo, users), so someone can e.g. review and remove bingo
incidents without being able to add any. The request method and path
decide the flag needed: GET is read except the new/duplicate forms, POST
to .../delete is delete, any other POST is write. Unmapped admin paths
are superuser-only, and a test enumerates every registered admin route
so a new one cannot ship without a rule.

Pages follow the same flags: nav and dashboard tiles show readable areas
only, write forms and delete buttons are hidden, and the datacenter form
is shown read-only.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 08:27:37 +04:00
commit 8471570187
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
13 changed files with 214 additions and 27 deletions

33
lib/admin_acl.rb Normal file
View file

@ -0,0 +1,33 @@
# Which permission an /admin request needs. Every admin path must map to
# an area (or be public/open); unmapped paths are superuser-only, and a
# test fails if any registered route is unmapped.
#
# GET/HEAD → read (except …/new and …/duplicate: write)
# POST …/delete, …/disable → delete
# any other non-GET → write
module AdminACL
RULES = [
[%r{\A/admin/(invoices|gel-rate|crypto-rate)(/|\z)}, 'invoices'],
[%r{\A/admin/bingo(/|\z)}, 'bingo'],
[%r{\A/admin/(users|audit)(/|\z)}, 'users'],
].freeze
module_function
def area_for(path)
RULES.find { |re, _| path.match?(re) }&.last
end
def perm_for(method, path)
get = %w[GET HEAD].include?(method)
return 'delete' if !get && path.match?(%r{/(delete|disable)\z})
return 'write' if !get || path.match?(%r{/(new|duplicate)\z})
'read'
end
def allowed?(user, method, path)
return true if user.superuser || path.match?(AdminAuth::OPEN)
area = area_for(path) or return false
user.can?(area, perm_for(method, path))
end
end

View file

@ -1,5 +1,6 @@
require 'rack/utils'
require_relative 'rate_limit'
require_relative 'admin_acl'
# Session login for /admin/*, replacing the old shared HTTP Basic gate.
#
@ -29,6 +30,11 @@ module AdminAuth
@current_user = user if user&.active && user.session_version == session[:uv]
end
# For views: show a form/button only if the user may use it.
def can?(area, perm)
current_user&.can?(area, perm) || false
end
def admin_path?(path = request.path_info)
path == '/admin' || path.start_with?('/admin/')
end
@ -67,7 +73,9 @@ module AdminAuth
if current_user.must_change_password && !request.path_info.match?(%r{\A/admin/(account/password|logout)\z})
redirect '/admin/account/password'
end
halt 403, 'Forbidden' unless current_user.superuser || request.path_info.match?(OPEN)
unless AdminACL.allowed?(current_user, request.request_method, request.path_info)
halt 403, 'You do not have access to this.'
end
end
app.get '/admin/login' do