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

View file

@ -37,6 +37,7 @@ Banking details and registration number are deliberately not on the public page
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
- `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min).
- Permissions (`lib/admin_acl.rb`): areas `invoices`, `bingo`, `users` × independent flags read/write/delete in `user_permissions` (read + delete without write is valid); superusers bypass. GET → read (…/new, …/duplicate → write), POST …/delete|…/disable → delete, other POST → write. Unmapped /admin paths are superuser-only, and `test/admin_acl_test.rb` fails if a registered admin route isn't mapped — add new admin routes to `AdminACL::RULES`. Views hide forms via `can?(area, perm)`; the server check is the real gate.
- Honeypot field named `website` for spam
- Static assets in `public/`; views in `views/`
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO).

View file

@ -136,23 +136,27 @@ class AsxpioWeb < Sinatra::Base
get '/admin' do
@page_title = 'Dashboard · admin'
@noindex = true
unpaid = Invoice.where(paid_at: nil).all
@inv = {
count: Invoice.count,
unpaid: unpaid,
overdue: unpaid.count { |i| i.due_on < Date.today },
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all
}
board = Bingo::Board.load
@bingo = {
cells: board.cells.size,
hit: board.hit.count(true),
lines: board.wins.size,
reserve: [Bingo::Datacenter.count - board.cells.size, 0].max,
last_incident: board.incidents.first,
no_photo: board.cells.reject(&:photo_key)
}
if can?('invoices', 'read')
unpaid = Invoice.where(paid_at: nil).all
@inv = {
count: Invoice.count,
unpaid: unpaid,
overdue: unpaid.count { |i| i.due_on < Date.today },
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
recent: Invoice.order(Sequel.desc(:created_at)).limit(5).all
}
end
if can?('bingo', 'read')
board = Bingo::Board.load
@bingo = {
cells: board.cells.size,
hit: board.hit.count(true),
lines: board.wins.size,
reserve: [Bingo::Datacenter.count - board.cells.size, 0].max,
last_incident: board.incidents.first,
no_photo: board.cells.reject(&:photo_key)
}
end
erb :'admin/dashboard'
end

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

View file

@ -359,6 +359,7 @@ a:hover {
.admin-nav-user { display: flex; gap: 8px; align-items: baseline; margin-left: 16px; padding-left: 16px; border-left: 1px solid var(--border); }
.admin-nav-user form { display: inline; }
.auth-form { max-width: 360px; margin: 0 auto; }
fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; }
.admin-nav a.active,
.admin-nav a:hover { color: var(--text-bright); }
.dash { display: grid; gap: 24px; }

116
test/admin_acl_test.rb Normal file
View file

@ -0,0 +1,116 @@
require_relative 'test_helper'
class AdminAclTest < Minitest::Test
include Rack::Test::Methods
include AdminLogin
def app
AsxpioWeb
end
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
TestDb.clean_users!
DB.connection[:bingo_incidents].delete
@dc = Bingo::Datacenter.order(:position).first ||
Bingo::Datacenter.create(slug: 'acl-dc', position: 1, name_ru: 'ЦОД', city_ru: 'Москва', operator: 'Op')
end
def as(perms)
u = User.create(username: 'user1', password: 'user-password-1')
perms.each do |area, flags|
UserPermission.create(user_id: u.id, area: area.to_s,
**flags.to_h { |f| [:"can_#{f}", true] })
end
admin_login!(username: 'user1', password: 'user-password-1')
end
def token
get '/admin'
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end
def forbidden?
last_response.status == 403
end
# Every registered /admin route is public, open, or mapped to an area.
def test_every_admin_route_is_mapped
unmapped = AsxpioWeb.routes.flat_map do |verb, routes|
routes.map { |r| [verb, r.first.to_s] }
end.select { |_, p| p.start_with?('/admin') }.reject do |_, p|
sample = p.gsub(/:\w+/, 'x')
sample.match?(AdminAuth::PUBLIC) || sample.match?(AdminAuth::OPEN) || AdminACL.area_for(sample)
end
assert_empty unmapped, "unmapped admin routes: #{unmapped.inspect}"
end
def test_perm_for
assert_equal 'read', AdminACL.perm_for('GET', '/admin/bingo')
assert_equal 'write', AdminACL.perm_for('GET', '/admin/bingo/dc/new')
assert_equal 'write', AdminACL.perm_for('GET', '/admin/invoices/abc/duplicate')
assert_equal 'write', AdminACL.perm_for('POST', '/admin/bingo/dc/1')
assert_equal 'delete', AdminACL.perm_for('POST', '/admin/bingo/incidents/1/delete')
assert_equal 'delete', AdminACL.perm_for('POST', '/admin/bingo/dc/1/photo/delete')
end
def test_bingo_read_only
as(bingo: %i[read])
get '/admin/bingo'
assert last_response.ok?
refute_includes last_response.body, 'Record incident'
refute_includes last_response.body, 'href="/admin/invoices"' # nav hides invoices
get "/admin/bingo/dc/#{@dc.id}"
assert last_response.ok?
assert_includes last_response.body, 'disabled'
refute_includes last_response.body, 'Danger zone'
get '/admin/bingo/dc/new'
assert forbidden?
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dc.id, occurred_on: '2026-10-01', kind: 'fire', title_ru: 'x' }
assert forbidden?
get '/admin/invoices'
assert forbidden?
end
def test_read_and_delete_without_write
inc = Bingo::Incident.create(datacenter_id: @dc.id, occurred_on: Date.today, kind: 'fire', title_ru: 'x')
as(bingo: %i[read delete])
post '/admin/bingo/incidents', { authenticity_token: token, datacenter_id: @dc.id, occurred_on: '2026-10-01', kind: 'fire', title_ru: 'y' }
assert forbidden?
get '/admin/bingo'
assert_includes last_response.body, "/admin/bingo/incidents/#{inc.id}/delete"
post "/admin/bingo/incidents/#{inc.id}/delete", { authenticity_token: token }
refute forbidden?
assert_nil Bingo::Incident[inc.id]
end
def test_invoices_read_only
as(invoices: %i[read])
get '/admin/invoices'
assert last_response.ok?
refute_includes last_response.body, '+ New invoice'
get '/admin/invoices/new'
assert forbidden?
post '/admin/invoices', { authenticity_token: token }
assert forbidden?
get '/admin/bingo'
assert forbidden?
get '/admin'
refute_includes last_response.body, '<h2><a href="/admin/bingo">'
end
def test_user_without_areas_sees_notice
as({})
get '/admin'
assert last_response.ok?
assert_includes last_response.body, 'No areas are assigned'
end
def test_superuser_sees_everything
admin_login!
get '/admin/bingo/dc/new'
assert last_response.ok?
get '/admin/invoices/new'
assert last_response.ok?
end
end

View file

@ -84,7 +84,7 @@ class AdminAuthTest < Minitest::Test
assert_equal 302, last_response.status
end
def test_non_superuser_limited_to_open_pages_in_phase_one
def test_user_without_permissions_limited_to_open_pages
User.create(username: 'bob', password: 'bob-password-12')
login('bob', 'bob-password-12')
get '/admin'

View file

@ -1,5 +1,6 @@
<nav class="admin-nav">
<% [['/admin', 'dashboard'], ['/admin/invoices', 'invoices'], ['/admin/bingo', 'bingo']].each do |href, label| %>
<% [['/admin', 'dashboard', nil], ['/admin/invoices', 'invoices', 'invoices'], ['/admin/bingo', 'bingo', 'bingo'], ['/admin/users', 'users', 'users']].each do |href, label, area| %>
<% next if area && !can?(area, 'read') %>
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
<% end %>
<% if current_user %>

View file

@ -11,17 +11,21 @@
<h2>Photo</h2>
<% if @dc.photo_file %>
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
<% if can?('bingo', 'delete') %>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">remove from card</button>
</form>
<% end %>
<% end %>
<% if can?('bingo', 'write') %>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="field"><label for="photo">Upload <span class="optional">(JPEG/PNG/WebP, ≤ 8 MB)</span></label><input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" /></div>
<div class="field"><label for="photo_url">…or import from URL <span class="optional">(blank = saved source URL)</span></label><input type="url" id="photo_url" name="url" placeholder="<%= @dc.photo_source_url %>" /></div>
<button type="submit" class="download-button">Store photo</button>
</form>
<% end %>
</section>
<% end %>
@ -29,6 +33,7 @@
<h2>Details</h2>
<form class="invoice-form" method="post" action="<%= fresh ? '/admin/bingo/dc' : "/admin/bingo/dc/#{@dc.id}" %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('bingo', 'write') %>>
<% if fresh %>
<div class="field"><label for="slug">Slug <span class="optional">(permanent; used in photo names, e.g. ix-mos7)</span></label><input type="text" id="slug" name="slug" value="<%= @dc.slug %>" required pattern="[a-z0-9]+(-[a-z0-9]+)*" /></div>
<% end %>
@ -60,11 +65,12 @@
<div class="field"><label for="photo_credit">Photo credit</label><input type="text" id="photo_credit" name="photo_credit" value="<%= @dc.photo_credit %>" /></div>
<div class="field"><label for="photo_source_url">Photo source URL</label><input type="url" id="photo_source_url" name="photo_source_url" value="<%= @dc.photo_source_url %>" /></div>
<div class="field"><label for="info_url">Info / fact source URL</label><input type="url" id="info_url" name="info_url" value="<%= @dc.info_url %>" /></div>
<button type="submit" class="download-button"><%= fresh ? 'Add datacenter' : 'Save' %></button>
<% if can?('bingo', 'write') %><button type="submit" class="download-button"><%= fresh ? 'Add datacenter' : 'Save' %></button><% end %>
</fieldset>
</form>
</section>
<% unless fresh %>
<% if !fresh && can?('bingo', 'delete') %>
<section class="section">
<h2>Danger zone</h2>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/delete" onsubmit="return confirm('Delete this datacenter and all its incidents? This cannot be undone.')">

View file

@ -8,6 +8,7 @@
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<% if can?('bingo', 'write') %>
<section class="section">
<h2>Record incident</h2>
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off">
@ -37,6 +38,7 @@
<button type="submit" class="download-button">Cross it out</button>
</form>
</section>
<% end %>
<section class="section">
<h2>Incidents</h2>
@ -53,10 +55,12 @@
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td>
<td><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
<td class="actions">
<% if can?('bingo', 'delete') %>
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete</button>
</form>
<% end %>
</td>
</tr>
<% end %>
@ -67,6 +71,7 @@
<section class="section">
<h2>Card</h2>
<% if can?('bingo', 'write') %>
<p><a href="/admin/bingo/dc/new">+ Add datacenter</a> · positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve</p>
<form method="post" action="/admin/bingo/sort" onsubmit="return confirm('Re-rank every datacenter by MW? Manual ordering is lost.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
@ -85,6 +90,9 @@
<input type="hidden" name="all" value="1" />
<button type="submit" class="link-button">↻ Re-import all photos from their source URLs</button>
</form>
<% else %>
<p class="optional">Positions 1–<%= Bingo::SIZE**2 %> are on the card, the rest are reserve.</p>
<% end %>
<table class="invoice-list">
<thead><tr><th>#</th><th>Photo</th><th>Name</th><th>Operator</th><th class="num">MW</th><th>Status</th><th class="num">Incidents</th><th></th></tr></thead>
<tbody>
@ -100,7 +108,7 @@
<td class="num mono"><%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %></td>
<td><%= dc.status %></td>
<td class="num mono"><%= dc.incidents.size %></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>"><%= can?('bingo', 'write') ? 'edit' : 'view' %></a></td>
</tr>
<% end %>
</tbody>

View file

@ -4,6 +4,7 @@
</div>
<div class="dash">
<% if @inv %>
<section class="section dash-tile">
<h2><a href="/admin/invoices">Invoices</a></h2>
<div class="stats">
@ -28,9 +29,11 @@
</tbody>
</table>
<% end %>
<p><a href="/admin/invoices/new">+ New invoice</a> · <a href="/admin/invoices">all invoices</a></p>
<p><% if can?('invoices', 'write') %><a href="/admin/invoices/new">+ New invoice</a> · <% end %><a href="/admin/invoices">all invoices</a></p>
</section>
<% end %>
<% if @bingo %>
<section class="section dash-tile">
<h2><a href="/admin/bingo">Bingo</a></h2>
<div class="stats">
@ -46,4 +49,8 @@
<% end %>
<p><a href="/admin/bingo">manage</a><% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %></p>
</section>
<% end %>
<% unless @inv || @bingo %>
<section class="section"><p>No areas are assigned to your account yet. Ask an administrator for access.</p></section>
<% end %>
</div>

View file

@ -1,12 +1,12 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Invoices</h1>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p>
<% if can?('invoices', 'write') %><p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p><% end %>
</div>
<% if @invoices.empty? %>
<section class="section">
<p>No invoices yet. <a href="/admin/invoices/new">Create the first one</a>.</p>
<p>No invoices yet.<% if can?('invoices', 'write') %> <a href="/admin/invoices/new">Create the first one</a>.<% end %></p>
</section>
<% else %>
<section class="section">

View file

@ -34,14 +34,16 @@
Share this URL with the client:<br />
<code class="mono">https://asxp.io/i/<%= @invoice.uuid %></code>
</p>
<% if can?('invoices', 'write') %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid" style="margin-top: 14px;">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit">
<% if @invoice.paid? %>Mark unpaid<% else %>Mark paid<% end %>
</button>
</form>
<% end %>
<p style="margin-top: 14px;">
<a href="/admin/invoices/<%= @invoice.uuid %>/duplicate">Use as template for a new invoice →</a>
<% if can?('invoices', 'write') %><a href="/admin/invoices/<%= @invoice.uuid %>/duplicate">Use as template for a new invoice →</a><% end %>
</p>
</section>