server: a non-admin account can manage its own uploads

Signing in and being allowed to administer the server were the same
question: the OIDC callback refused a session outright to anyone outside
the admin group. A legitimate user could authenticate, be told what they
could not do, and be left with no way to see or delete the data their own
devices had uploaded.

They are separate questions now. Everyone who authenticates gets a
session; the admin flag rides inside the MAC'd payload, so promoting
yourself means forging a signature rather than editing a cookie, and a
role that does not parse fails closed to "user".

Pages scope themselves through visibleDevices/mayTouchRun rather than
filtering individually — per-page scoping is what the next page added
will be missing, and that failure is silent, since a listing that leaks
other people's uploads looks exactly like one that does not. Someone
else's run answers 404, not 403: a distinguishable refusal would confirm
the run exists. Revoking devices and minting enrolment tokens affect the
whole server and stay behind adminOnly at the route table, where someone
looking for who-may-do-what will actually find it.

Ownership is re-read per request instead of captured at sign-in, so
unlinking an account takes effect immediately rather than at session
expiry. Tests cover that, plus the degenerate case of an empty subject,
which must own nothing rather than everything with an empty account id.

Also: attribute the ICMPv6 finding per network. It compared "is IPv6
configured anywhere on this device" against "did any network answer",
which on a phone reports IPv6-is-broken about a network where IPv6 was
never configured. network_ref is null on every test, so the probe now
records per-network outcomes structurally rather than as prose a finding
would have to parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 21:10:38 +02:00
co-authored by Claude Opus 5
parent 7eaf0c4190
commit 7a5004f293
8 changed files with 404 additions and 64 deletions
+62 -7
View File
@@ -16,6 +16,46 @@ import (
"echo-lot.app/server/internal/store"
)
// visibleDevices returns the devices a session may see: everything for an administrator, and for
// anyone else the devices linked to their own account.
//
// Every page goes through this rather than filtering for itself. Scoping applied per-page is
// scoping that will be missing from the next page someone adds, and the failure is silent — a
// listing that quietly shows other people's uploads looks exactly like one that does not.
func (s *Server) visibleDevices(sess *adminauth.Session) []store.Device {
all := s.Store.Devices()
if sess.Admin {
return all
}
owned := make(map[string]bool)
for _, id := range s.Store.DeviceIDsForAccount(sess.Subject) {
owned[id] = true
}
out := make([]store.Device, 0, len(owned))
for _, d := range all {
if owned[d.ID] {
out = append(out, d)
}
}
return out
}
// mayTouchRun reports whether this session may read or delete a given run.
//
// Checked against the device list rather than against the run's own metadata, so an unlinked or
// revoked device stops granting access the moment the link is gone.
func (s *Server) mayTouchRun(sess *adminauth.Session, device string) bool {
if sess.Admin {
return true
}
for _, d := range s.visibleDevices(sess) {
if d.ID == device {
return true
}
}
return false
}
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
if s.session(r) != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -29,7 +69,7 @@ func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
devices := s.Store.Devices()
devices := s.visibleDevices(sess)
linked := 0
for _, d := range devices {
if d.LinkedToAccount() {
@@ -37,7 +77,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
}
}
var selftest any
if s.SelfTest != nil {
// The self-test describes the server's own health, which is an operator's concern; a user
// looking at their uploads has no use for it and no ability to act on it.
if s.SelfTest != nil && sess.Admin {
selftest = s.SelfTest()
}
s.render(w, r, "dashboard", map[string]any{
@@ -48,6 +90,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
"Runs": s.totalRuns(devices),
"SelfTest": selftest,
"Version": s.Version,
"Admin": sess.Admin,
})
}
@@ -63,7 +106,7 @@ func (s *Server) totalRuns(devices []store.Device) int {
}
func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
devices := s.Store.Devices()
devices := s.visibleDevices(sess)
// Newest first: the device someone is looking for is almost always the one just enrolled.
sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) })
@@ -81,7 +124,7 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth
}
s.render(w, r, "devices", map[string]any{
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
"Link": r.URL.Query().Get("link"),
"Link": r.URL.Query().Get("link"), "Admin": sess.Admin,
})
}
@@ -118,7 +161,7 @@ func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminaut
DeviceName string
}
var rows []row
for _, d := range s.Store.Devices() {
for _, d := range s.visibleDevices(sess) {
if s.Runs == nil {
break
}
@@ -134,10 +177,18 @@ func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminaut
if len(rows) > 200 {
rows = rows[:200] // a page, not the archive; the count is on the dashboard
}
s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows})
s.render(w, r, "runs", map[string]any{
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, "Admin": sess.Admin,
})
}
func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
// 404 rather than 403 for someone else's run: a distinguishable "you may not see this" tells
// an unauthorised caller that the run exists, which is itself something they should not learn.
if !s.mayTouchRun(sess, r.PathValue("device")) {
http.NotFound(w, r)
return
}
body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
if err != nil {
http.NotFound(w, r)
@@ -151,7 +202,7 @@ func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth
out = body
}
s.render(w, r, "run", map[string]any{
"Session": sess, "CSRF": s.csrfToken(sess),
"Session": sess, "CSRF": s.csrfToken(sess), "Admin": sess.Admin,
"Device": r.PathValue("device"), "ID": r.PathValue("id"),
"JSON": string(out),
})
@@ -159,6 +210,10 @@ func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth
func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
device, id := r.PathValue("device"), r.PathValue("id")
if !s.mayTouchRun(sess, device) {
http.NotFound(w, r)
return
}
if err := s.Runs.Delete(device, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return