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:
co-authored by
Claude Opus 5
parent
7eaf0c4190
commit
7a5004f293
@@ -387,17 +387,54 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
* Was IPv6 provisioned on the network a test actually ran over?
|
||||||
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
*
|
||||||
|
* This deliberately asks about one network rather than about the device. Answering "does any
|
||||||
|
* network here have IPv6" produces a real false positive on a phone, and it is not hypothetical:
|
||||||
|
* an IPv4-only wifi with working cellular alongside it reports "IPv6 is configured, but ICMPv6
|
||||||
|
* gets no reply" — configured on cellular, pinged over wifi, and the two never met.
|
||||||
|
*
|
||||||
|
* A global (non-link-local) address or a v6 default route means the network claims to offer
|
||||||
|
* IPv6; link-local only does not count, since every interface has one.
|
||||||
*/
|
*/
|
||||||
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
|
private fun ipv6Provisioned(
|
||||||
networks.any { n ->
|
networks: List<app.echo_lot.measurement.Network>,
|
||||||
|
networkRef: String?,
|
||||||
|
): Boolean {
|
||||||
|
// No reference means the test was not per-network; fall back to the device-wide reading
|
||||||
|
// rather than silently reporting nothing.
|
||||||
|
val scope = networks.filter { networkRef == null || it.id == networkRef }
|
||||||
|
return scope.any { n ->
|
||||||
n.link.addresses.any { a ->
|
n.link.addresses.any { a ->
|
||||||
a.addr.contains(':') &&
|
a.addr.contains(':') &&
|
||||||
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
||||||
!a.addr.startsWith("::1")
|
!a.addr.startsWith("::1")
|
||||||
} || n.link.routes.any { it.dst == "::/0" }
|
} || n.link.routes.any { it.dst == "::/0" }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-network ICMP outcomes, keyed by network id.
|
||||||
|
*
|
||||||
|
* Reads the structured evidence the probe records rather than its prose detail — a finding
|
||||||
|
* that depended on the wording of a human-readable string would break silently the first time
|
||||||
|
* that wording improved.
|
||||||
|
*/
|
||||||
|
private fun icmpResults(t: Test): Map<String, Boolean> {
|
||||||
|
val out = HashMap<String, Boolean>()
|
||||||
|
val ev = t.evidence ?: return out
|
||||||
|
for ((_, v) in ev) {
|
||||||
|
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
|
val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue
|
||||||
|
val ok = (o["ok"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
|
||||||
|
out[ref] = ok
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-facing name for the network a test ran over; falls back to something readable. */
|
||||||
|
private fun ifaceOf(networks: List<app.echo_lot.measurement.Network>, ref: String?): String =
|
||||||
|
networks.firstOrNull { it.id == ref }?.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
|
||||||
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
|
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
|
||||||
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
||||||
@@ -526,31 +563,55 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
if (t.type == TestType.ICMP_PING6) {
|
||||||
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
|
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only, and
|
||||||
// and that is not a defect. What IS a defect is IPv6 that the network claims to
|
// that is not a defect. What IS a defect is IPv6 the network claims to provide (a
|
||||||
// provide (a global address or a default route from RA/DHCPv6) but that does not
|
// global address or a default route from RA/DHCPv6) that does not work: that causes
|
||||||
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
|
// Happy-Eyeballs delays, timeouts and hangs.
|
||||||
// depends on whether v6 was provisioned at all.
|
//
|
||||||
if (ipv6Provisioned(networks)) {
|
// Judged per network, from the per-network evidence rather than the aggregate
|
||||||
|
// status. The aggregate can only say "some network answered", and on a phone with
|
||||||
|
// wifi and cellular up at once that is how "IPv6 is configured but gets no reply"
|
||||||
|
// ends up describing a network where IPv6 was never configured in the first place.
|
||||||
|
val results = icmpResults(t)
|
||||||
|
var anyV6Network = false
|
||||||
|
for (n in networks) {
|
||||||
|
val provisioned = ipv6Provisioned(networks, n.id)
|
||||||
|
if (provisioned) anyV6Network = true
|
||||||
|
val ok = results[n.id] ?: continue
|
||||||
|
if (!provisioned || ok) continue
|
||||||
|
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
out.add(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||||
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||||
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity, confidence = Confidence.MEDIUM,
|
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||||
title = "IPv6 is configured, but ICMPv6 gets no reply",
|
confidence = Confidence.MEDIUM,
|
||||||
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 echo got no reply on any network. That has two explanations which look identical from here: IPv6 is broken, or ICMPv6 is filtered while IPv6 itself works. Filtering is common and is a fault in its own right — it breaks Path MTU Discovery, so large packets vanish rather than being reported as too big.",
|
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
|
||||||
|
description = "$where advertises IPv6 (a global address and/or a " +
|
||||||
|
"default route), but ICMPv6 echo got no reply over it. That has " +
|
||||||
|
"two explanations which look identical from here: IPv6 is broken, " +
|
||||||
|
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
|
||||||
|
"common and is a fault in its own right — it breaks Path MTU " +
|
||||||
|
"Discovery, so large packets vanish rather than being reported as " +
|
||||||
|
"too big.",
|
||||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
if (!anyV6Network) {
|
||||||
|
// Said once for the device, not once per interface: "this network is IPv4-only"
|
||||||
|
// repeated per interface reads as several problems instead of one observation.
|
||||||
out.add(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
||||||
category = FindingRegistry.V6_NOT_OFFERED.category,
|
category = FindingRegistry.V6_NOT_OFFERED.category,
|
||||||
severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH,
|
severity = FindingRegistry.V6_NOT_OFFERED.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
title = "IPv4-only network (no IPv6 offered)",
|
title = "IPv4-only network (no IPv6 offered)",
|
||||||
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
|
description = "No IPv6 address or default route was provisioned on " +
|
||||||
|
"any active network, so IPv6 tests could not run. This is normal " +
|
||||||
|
"— many networks are still IPv4-only and it is not a fault.",
|
||||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,24 +40,35 @@ class IcmpProbe(
|
|||||||
|
|
||||||
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
val b = TestBuilder(type, tier, ids)
|
val b = TestBuilder(type, tier, ids)
|
||||||
val perNetwork = LinkedHashMap<String, String>()
|
val perNetwork = LinkedHashMap<String, Triple<String?, Boolean, String>>()
|
||||||
var anyOk = false
|
var anyOk = false
|
||||||
val rtts = ArrayList<Double>()
|
val rtts = ArrayList<Double>()
|
||||||
|
|
||||||
// Default network first, then each active network explicitly.
|
// Default network first, then each active network explicitly.
|
||||||
attempt(null).let { (ok, detail, rtt) ->
|
attempt(null).let { (ok, detail, rtt) ->
|
||||||
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
perNetwork["default"] = Triple(null, ok, detail); if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
}
|
}
|
||||||
for (e in entries) {
|
for (e in entries) {
|
||||||
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||||
val (ok, detail, rtt) = attempt(e.handle)
|
val (ok, detail, rtt) = attempt(e.handle)
|
||||||
perNetwork[label] = detail
|
perNetwork[label] = Triple(e.model.id, ok, detail)
|
||||||
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-network results are recorded structurally, not just as prose. The aggregate status
|
||||||
|
// can only say "some network answered"; a finding needs to know *which* network failed,
|
||||||
|
// and recovering that by parsing a human-readable detail string would be a trap waiting to
|
||||||
|
// spring the first time the wording changes.
|
||||||
val evidence: JsonObject = buildJsonObject {
|
val evidence: JsonObject = buildJsonObject {
|
||||||
put("target", target)
|
put("target", target)
|
||||||
for ((k, v) in perNetwork) put(k, v)
|
for ((label, r) in perNetwork) {
|
||||||
|
val (netId, ok, detail) = r
|
||||||
|
put(label, buildJsonObject {
|
||||||
|
netId?.let { put("network_ref", it) }
|
||||||
|
put("ok", ok)
|
||||||
|
put("detail", detail)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val metrics: JsonObject = buildJsonObject {
|
val metrics: JsonObject = buildJsonObject {
|
||||||
put("networks_ok", rtts.size)
|
put("networks_ok", rtts.size)
|
||||||
|
|||||||
@@ -166,12 +166,21 @@ func (t *Throttle) Succeeded() {
|
|||||||
|
|
||||||
// ---- sessions ---------------------------------------------------------------------------
|
// ---- sessions ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Session is an authenticated admin, however they proved it.
|
// Session is an authenticated account, however it proved itself. Not necessarily an admin:
|
||||||
|
// signing in and being allowed to administer the server are separate questions, and a plain user
|
||||||
|
// gets a session so they can manage their own uploads.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
// Subject is the account id: "local:<username>" or "<issuer>#<sub>" from OIDC.
|
// Subject is the account id: "local:<username>" or "<issuer>#<sub>" from OIDC.
|
||||||
Subject string
|
Subject string
|
||||||
// Display is what the UI shows.
|
// Display is what the UI shows.
|
||||||
Display string
|
Display string
|
||||||
|
// Admin is authorisation, decided at sign-in and carried inside the signed payload.
|
||||||
|
//
|
||||||
|
// Inside, specifically — not derived later from the subject, and not stored beside the MAC.
|
||||||
|
// A flag outside the signature is a privilege escalation anyone can perform with a text
|
||||||
|
// editor, and re-deriving it per request would mean re-reading group membership from the IdP
|
||||||
|
// on a path that has no token to do it with.
|
||||||
|
Admin bool
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,12 +212,16 @@ func NewSecret() ([]byte, error) {
|
|||||||
|
|
||||||
var ErrSession = errors.New("session is not valid")
|
var ErrSession = errors.New("session is not valid")
|
||||||
|
|
||||||
// Issue returns the cookie value for a newly authenticated admin.
|
// Issue returns the cookie value for a newly authenticated account.
|
||||||
func (s *Sessions) Issue(subject, display string) string {
|
func (s *Sessions) Issue(subject, display string, admin bool) string {
|
||||||
exp := time.Now().Add(s.ttl).Unix()
|
exp := time.Now().Add(s.ttl).Unix()
|
||||||
|
role := "u"
|
||||||
|
if admin {
|
||||||
|
role = "a"
|
||||||
|
}
|
||||||
payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." +
|
payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." +
|
||||||
base64.RawURLEncoding.EncodeToString([]byte(display)) + "." +
|
base64.RawURLEncoding.EncodeToString([]byte(display)) + "." +
|
||||||
strconv.FormatInt(exp, 10)
|
strconv.FormatInt(exp, 10) + "." + role
|
||||||
return payload + "." + s.mac(payload)
|
return payload + "." + s.mac(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +238,7 @@ func (s *Sessions) Parse(value string) (*Session, error) {
|
|||||||
return nil, ErrSession
|
return nil, ErrSession
|
||||||
}
|
}
|
||||||
parts := strings.Split(payload, ".")
|
parts := strings.Split(payload, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 4 {
|
||||||
return nil, ErrSession
|
return nil, ErrSession
|
||||||
}
|
}
|
||||||
subject, err := base64.RawURLEncoding.DecodeString(parts[0])
|
subject, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
@@ -243,7 +256,13 @@ func (s *Sessions) Parse(value string) (*Session, error) {
|
|||||||
if time.Now().After(time.Unix(exp, 0)) {
|
if time.Now().After(time.Unix(exp, 0)) {
|
||||||
return nil, fmt.Errorf("%w: expired", ErrSession)
|
return nil, fmt.Errorf("%w: expired", ErrSession)
|
||||||
}
|
}
|
||||||
return &Session{Subject: string(subject), Display: string(display), Expires: time.Unix(exp, 0)}, nil
|
// Anything that is not exactly the admin marker is a user. A malformed role must fail closed:
|
||||||
|
// the safe reading of an unparseable privilege claim is the smaller privilege.
|
||||||
|
admin := parts[3] == "a"
|
||||||
|
return &Session{
|
||||||
|
Subject: string(subject), Display: string(display),
|
||||||
|
Admin: admin, Expires: time.Unix(exp, 0),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sessions) mac(payload string) string {
|
func (s *Sessions) mac(payload string) string {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package adminauth
|
package adminauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -96,7 +98,7 @@ func TestAnEmptyCredentialNeverVerifies(t *testing.T) {
|
|||||||
func TestSessionRoundTrip(t *testing.T) {
|
func TestSessionRoundTrip(t *testing.T) {
|
||||||
secret, _ := NewSecret()
|
secret, _ := NewSecret()
|
||||||
s := NewSessions(secret, time.Hour)
|
s := NewSessions(secret, time.Hour)
|
||||||
got, err := s.Parse(s.Issue("local:admin", "Admin"))
|
got, err := s.Parse(s.Issue("local:admin", "Admin", true))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ func TestSessionRoundTrip(t *testing.T) {
|
|||||||
func TestTamperedSessionsAreRejected(t *testing.T) {
|
func TestTamperedSessionsAreRejected(t *testing.T) {
|
||||||
secret, _ := NewSecret()
|
secret, _ := NewSecret()
|
||||||
s := NewSessions(secret, time.Hour)
|
s := NewSessions(secret, time.Hour)
|
||||||
good := s.Issue("local:admin", "Admin")
|
good := s.Issue("local:admin", "Admin", true)
|
||||||
|
|
||||||
parts := strings.Split(good, ".")
|
parts := strings.Split(good, ".")
|
||||||
tampered := []string{
|
tampered := []string{
|
||||||
@@ -131,7 +133,7 @@ func TestTamperedSessionsAreRejected(t *testing.T) {
|
|||||||
func TestSessionsFromAnotherSecretAreRejected(t *testing.T) {
|
func TestSessionsFromAnotherSecretAreRejected(t *testing.T) {
|
||||||
a, _ := NewSecret()
|
a, _ := NewSecret()
|
||||||
b, _ := NewSecret()
|
b, _ := NewSecret()
|
||||||
issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin")
|
issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin", true)
|
||||||
if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil {
|
if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil {
|
||||||
t.Fatal("a session signed with a different secret was accepted — rotating the secret " +
|
t.Fatal("a session signed with a different secret was accepted — rotating the secret " +
|
||||||
"must invalidate every existing session")
|
"must invalidate every existing session")
|
||||||
@@ -143,7 +145,7 @@ func TestExpiredSessionsAreRejected(t *testing.T) {
|
|||||||
// A negative TTL is not reachable through NewSessions, so issue with a real one and check
|
// A negative TTL is not reachable through NewSessions, so issue with a real one and check
|
||||||
// the boundary via a session that has already run out.
|
// the boundary via a session that has already run out.
|
||||||
s := NewSessions(secret, time.Millisecond)
|
s := NewSessions(secret, time.Millisecond)
|
||||||
v := s.Issue("local:admin", "Admin")
|
v := s.Issue("local:admin", "Admin", true)
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
if _, err := s.Parse(v); err == nil {
|
if _, err := s.Parse(v); err == nil {
|
||||||
t.Fatal("an expired session was accepted")
|
t.Fatal("an expired session was accepted")
|
||||||
@@ -201,3 +203,46 @@ func TestThrottleForgivesAfterAQuietPeriod(t *testing.T) {
|
|||||||
t.Fatalf("an operator returning later was still throttled: %v", d)
|
t.Fatalf("an operator returning later was still throttled: %v", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The admin flag is an authorisation decision carried in a cookie the client holds, so the
|
||||||
|
// interesting cases are all about what happens when the client lies about it.
|
||||||
|
func TestSessionAdminFlag(t *testing.T) {
|
||||||
|
s := NewSessions([]byte("secret"), time.Hour)
|
||||||
|
|
||||||
|
t.Run("round trips both ways", func(t *testing.T) {
|
||||||
|
admin, err := s.Parse(s.Issue("local:admin", "Admin", true))
|
||||||
|
if err != nil || !admin.Admin {
|
||||||
|
t.Fatalf("admin session did not survive: %+v err=%v", admin, err)
|
||||||
|
}
|
||||||
|
user, err := s.Parse(s.Issue("oidc#1", "Markus", false))
|
||||||
|
if err != nil || user.Admin {
|
||||||
|
t.Fatalf("user session came back as admin: %+v err=%v", user, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("promoting yourself invalidates the cookie", func(t *testing.T) {
|
||||||
|
// The whole point of putting the flag inside the MAC: editing it must break the signature
|
||||||
|
// rather than produce a valid admin session.
|
||||||
|
v := s.Issue("oidc#1", "Markus", false)
|
||||||
|
i := strings.LastIndex(v, ".")
|
||||||
|
tampered := strings.TrimSuffix(v[:i], ".u") + ".a" + v[i:]
|
||||||
|
if got, err := s.Parse(tampered); err == nil {
|
||||||
|
t.Fatalf("a self-promoted cookie was accepted as %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an unparseable role is not an admin", func(t *testing.T) {
|
||||||
|
// Fail closed: whatever a malformed privilege claim means, it does not mean "more access".
|
||||||
|
// Signed by us, so it passes the MAC — only the role parsing stands between it and admin.
|
||||||
|
exp := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||||
|
payload := base64.RawURLEncoding.EncodeToString([]byte("oidc#1")) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString([]byte("Markus")) + "." + exp + ".ADMIN"
|
||||||
|
sess, err := s.Parse(payload + "." + s.mac(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected parse error: %v", err)
|
||||||
|
}
|
||||||
|
if sess.Admin {
|
||||||
|
t.Fatal("a role of \"ADMIN\" was treated as the admin marker")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,13 +83,17 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
mux.HandleFunc("GET /admin/callback", s.oidcCallback)
|
||||||
mux.HandleFunc("POST /logout", s.logout)
|
mux.HandleFunc("POST /logout", s.logout)
|
||||||
|
|
||||||
|
// Any signed-in account. These handlers scope what they show to the session themselves —
|
||||||
|
// an admin sees everything, a user sees their own devices and runs.
|
||||||
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
mux.HandleFunc("GET /", s.guard(s.dashboard))
|
||||||
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
mux.HandleFunc("GET /devices", s.guard(s.devices))
|
||||||
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.revokeDevice))
|
|
||||||
mux.HandleFunc("POST /enroll-tokens", s.guard(s.mintToken))
|
|
||||||
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
mux.HandleFunc("GET /runs", s.guard(s.runsList))
|
||||||
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
mux.HandleFunc("GET /runs/{device}/{id}", s.guard(s.runView))
|
||||||
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
mux.HandleFunc("POST /runs/{device}/{id}/delete", s.guard(s.runDelete))
|
||||||
|
// Deleting your own upload is yours to do; revoking a device or minting an enrolment token
|
||||||
|
// affects the whole server, so those stay with the admin.
|
||||||
|
mux.HandleFunc("POST /devices/{id}/revoke", s.guard(s.adminOnly(s.revokeDevice)))
|
||||||
|
mux.HandleFunc("POST /enroll-tokens", s.guard(s.adminOnly(s.mintToken)))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -140,10 +144,28 @@ func (s *Server) csrfOK(r *http.Request, sess *adminauth.Session) bool {
|
|||||||
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
return r.PostFormValue(csrfField) == s.csrfToken(sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setSession(w http.ResponseWriter, subject, display string) {
|
// adminOnly refuses a handler to a signed-in account that is not an administrator.
|
||||||
|
//
|
||||||
|
// A separate wrapper rather than a check inside each handler: an authorisation rule that has to be
|
||||||
|
// remembered in every handler is one that will eventually be forgotten in a new one, and the route
|
||||||
|
// table is where someone looks to find out who may do what.
|
||||||
|
func (s *Server) adminOnly(
|
||||||
|
h func(http.ResponseWriter, *http.Request, *adminauth.Session),
|
||||||
|
) func(http.ResponseWriter, *http.Request, *adminauth.Session) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
|
if !sess.Admin {
|
||||||
|
slog.Info("admin action refused", "account", sess.Subject, "path", r.URL.Path)
|
||||||
|
http.Error(w, "that action needs an administrator account", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(w, r, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setSession(w http.ResponseWriter, subject, display string, admin bool) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: s.Sessions.Issue(subject, display),
|
Value: s.Sessions.Issue(subject, display, admin),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
HttpOnly: true, // the cookie is a bearer credential; script has no business reading it
|
||||||
Secure: s.Secure,
|
Secure: s.Secure,
|
||||||
@@ -186,7 +208,7 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.Throttle.Succeeded()
|
s.Throttle.Succeeded()
|
||||||
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
slog.Info("admin login", "user", user, "method", "local", "from", clientIP(r))
|
||||||
s.setSession(w, "local:"+cred.Username, cred.Username)
|
s.setSession(w, "local:"+cred.Username, cred.Username, true)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,18 +293,14 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
http.Error(w, "the identity token was not accepted", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.OIDC.IsAdmin(claims) {
|
// Authentication and authorisation are answered separately here. Someone who is not in the
|
||||||
// Named explicitly: "you signed in but you are not an admin" is a different problem from
|
// admin group has still proved who they are, and their own uploads are their business to
|
||||||
// "your password is wrong", and the group is the thing to go and check.
|
// manage — refusing them a session outright, as this used to, left a legitimate account with
|
||||||
slog.Info("admin access denied: not in group", "account", claims.AccountID(),
|
// no way to see or delete the data it had sent.
|
||||||
"want_group", s.OIDC.Config().AdminGroup, "have", claims.Groups)
|
admin := s.OIDC.IsAdmin(claims)
|
||||||
http.Error(w, fmt.Sprintf(
|
slog.Info("login", "account", claims.AccountID(), "method", "oidc", "admin", admin,
|
||||||
"Signed in as %s, but that account is not in the %q group, so it cannot administer "+
|
"from", clientIP(r))
|
||||||
"this server.", claims.Display(), s.OIDC.Config().AdminGroup), http.StatusForbidden)
|
s.setSession(w, claims.AccountID(), claims.Display(), admin)
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("admin login", "account", claims.AccountID(), "method", "oidc", "from", clientIP(r))
|
|
||||||
s.setSession(w, claims.AccountID(), claims.Display())
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,46 @@ import (
|
|||||||
"echo-lot.app/server/internal/store"
|
"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) {
|
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.session(r) != nil {
|
if s.session(r) != nil {
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
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) {
|
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
devices := s.Store.Devices()
|
devices := s.visibleDevices(sess)
|
||||||
linked := 0
|
linked := 0
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
if d.LinkedToAccount() {
|
if d.LinkedToAccount() {
|
||||||
@@ -37,7 +77,9 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminau
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var selftest any
|
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()
|
selftest = s.SelfTest()
|
||||||
}
|
}
|
||||||
s.render(w, r, "dashboard", map[string]any{
|
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),
|
"Runs": s.totalRuns(devices),
|
||||||
"SelfTest": selftest,
|
"SelfTest": selftest,
|
||||||
"Version": s.Version,
|
"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) {
|
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.
|
// 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) })
|
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{
|
s.render(w, r, "devices", map[string]any{
|
||||||
"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows,
|
"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
|
DeviceName string
|
||||||
}
|
}
|
||||||
var rows []row
|
var rows []row
|
||||||
for _, d := range s.Store.Devices() {
|
for _, d := range s.visibleDevices(sess) {
|
||||||
if s.Runs == nil {
|
if s.Runs == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -134,10 +177,18 @@ func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminaut
|
|||||||
if len(rows) > 200 {
|
if len(rows) > 200 {
|
||||||
rows = rows[:200] // a page, not the archive; the count is on the dashboard
|
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) {
|
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"))
|
body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -151,7 +202,7 @@ func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth
|
|||||||
out = body
|
out = body
|
||||||
}
|
}
|
||||||
s.render(w, r, "run", map[string]any{
|
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"),
|
"Device": r.PathValue("device"), "ID": r.PathValue("id"),
|
||||||
"JSON": string(out),
|
"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) {
|
func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) {
|
||||||
device, id := r.PathValue("device"), r.PathValue("id")
|
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 {
|
if err := s.Runs.Delete(device, id); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const baseHTML = `<!doctype html>
|
|||||||
<header>
|
<header>
|
||||||
<h1>Echolot</h1>
|
<h1>Echolot</h1>
|
||||||
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
||||||
<span class="who">{{.Session.Display}}
|
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} <span class="muted">(your account)</span>{{end}}
|
||||||
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -94,18 +94,28 @@ const baseHTML = `<!doctype html>
|
|||||||
|
|
||||||
{{else if eq .Page "dashboard"}}
|
{{else if eq .Page "dashboard"}}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="stat"><b>{{.Devices}}</b><span>devices</span></div>
|
<div class="stat"><b>{{.Devices}}</b><span>{{if .Admin}}devices{{else}}your devices{{end}}</span></div>
|
||||||
<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>
|
{{if .Admin}}<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>{{end}}
|
||||||
<div class="stat"><b>{{.Runs}}</b><span>stored runs</span></div>
|
<div class="stat"><b>{{.Runs}}</b><span>{{if .Admin}}stored runs{{else}}your runs{{end}}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if not .Admin}}
|
||||||
|
<div class="card">
|
||||||
|
<p>This is your account. You can see the devices you have signed in on, review everything
|
||||||
|
they have uploaded, and delete any of it.</p>
|
||||||
|
<p class="muted">Administering the server — enrolling devices, revoking them, and
|
||||||
|
seeing other people's uploads — needs an administrator account.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Admin}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Server</h3>
|
<h3>Server</h3>
|
||||||
<p class="muted">version {{.Version}}</p>
|
<p class="muted">version {{.Version}}</p>
|
||||||
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{else if eq .Page "devices"}}
|
{{else if eq .Page "devices"}}
|
||||||
<h2>Devices</h2>
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
||||||
{{with .Link}}
|
{{with .Link}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
||||||
@@ -114,10 +124,12 @@ const baseHTML = `<!doctype html>
|
|||||||
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Admin}}
|
||||||
<form method="post" action="/enroll-tokens">
|
<form method="post" action="/enroll-tokens">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button>Create enrolment link</button>
|
<button>Create enrolment link</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
@@ -127,17 +139,17 @@ const baseHTML = `<!doctype html>
|
|||||||
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
||||||
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
||||||
<td>{{.Runs}}</td>
|
<td>{{.Runs}}</td>
|
||||||
<td><form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
<td>{{if $.Admin}}<form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<button class="danger">Revoke</button></form></td>
|
<button class="danger">Revoke</button></form>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6" class="muted">No devices enrolled.</td></tr>
|
<tr><td colspan="6" class="muted">{{if $.Admin}}No devices enrolled.{{else}}You have not signed in on any device yet. Sign in from the Echolot app to link one.{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{{else if eq .Page "runs"}}
|
{{else if eq .Page "runs"}}
|
||||||
<h2>Uploaded runs</h2>
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
||||||
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
||||||
here can un-redact a run.</p>
|
here can un-redact a run.</p>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package adminui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"echo-lot.app/server/internal/adminauth"
|
||||||
|
"echo-lot.app/server/internal/runs"
|
||||||
|
"echo-lot.app/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Two accounts, one device each, plus an unlinked device nobody owns.
|
||||||
|
func fixture(t *testing.T) (*Server, string, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rs, err := runs.Open(dir, runs.DefaultPolicy())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enroll := func(name string) string {
|
||||||
|
tok, err := st.NewEnrollToken(time.Hour, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d, err := st.Redeem(tok, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d.ID
|
||||||
|
}
|
||||||
|
mine, theirs, orphan := enroll("mine"), enroll("theirs"), enroll("orphan")
|
||||||
|
if err := st.LinkAccount(mine, "oidc#me", "Me"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.LinkAccount(theirs, "oidc#you", "You"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, d := range []string{mine, theirs, orphan} {
|
||||||
|
if _, err := rs.Put(d, []byte(`{"run":{"id":"r"}}`), true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Server{Store: st, Runs: rs}, mine, theirs, orphan
|
||||||
|
}
|
||||||
|
|
||||||
|
func user() *adminauth.Session { return &adminauth.Session{Subject: "oidc#me", Display: "Me"} }
|
||||||
|
func admin() *adminauth.Session { return &adminauth.Session{Subject: "local:a", Admin: true} }
|
||||||
|
|
||||||
|
func TestVisibleDevicesScopesToAccount(t *testing.T) {
|
||||||
|
s, mine, theirs, orphan := fixture(t)
|
||||||
|
|
||||||
|
got := s.visibleDevices(user())
|
||||||
|
if len(got) != 1 || got[0].ID != mine {
|
||||||
|
t.Fatalf("a user should see only their own device, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
all := s.visibleDevices(admin())
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("an admin should see every device, got %d", len(all))
|
||||||
|
}
|
||||||
|
_ = theirs
|
||||||
|
_ = orphan
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlinkedDevicesBelongToNobody(t *testing.T) {
|
||||||
|
// An enrolled but never-signed-in device is not "everyone's" — a user must not inherit it
|
||||||
|
// just because no account claimed it.
|
||||||
|
s, _, _, orphan := fixture(t)
|
||||||
|
if s.mayTouchRun(user(), orphan) {
|
||||||
|
t.Fatal("an unlinked device was treated as the user's own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAccessFollowsDeviceOwnership(t *testing.T) {
|
||||||
|
s, mine, theirs, _ := fixture(t)
|
||||||
|
|
||||||
|
if !s.mayTouchRun(user(), mine) {
|
||||||
|
t.Fatal("a user cannot reach their own run")
|
||||||
|
}
|
||||||
|
if s.mayTouchRun(user(), theirs) {
|
||||||
|
t.Fatal("a user reached someone else's run")
|
||||||
|
}
|
||||||
|
if !s.mayTouchRun(admin(), theirs) {
|
||||||
|
t.Fatal("an admin should reach any run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessEndsWhenTheLinkDoes(t *testing.T) {
|
||||||
|
// Ownership is read from the device list on every request rather than captured at sign-in,
|
||||||
|
// so unlinking takes effect immediately — a session issued while linked must not keep working.
|
||||||
|
s, mine, _, _ := fixture(t)
|
||||||
|
sess := user()
|
||||||
|
if !s.mayTouchRun(sess, mine) {
|
||||||
|
t.Fatal("precondition: the device should start out owned")
|
||||||
|
}
|
||||||
|
if err := s.Store.LinkAccount(mine, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.mayTouchRun(sess, mine) {
|
||||||
|
t.Fatal("access survived the account link being removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptySubjectMatchesNothing(t *testing.T) {
|
||||||
|
// The dangerous degenerate case: a session with no subject must own nothing, not everything
|
||||||
|
// that happens to have an empty account id.
|
||||||
|
s, _, _, _ := fixture(t)
|
||||||
|
anon := &adminauth.Session{Subject: "", Display: ""}
|
||||||
|
if got := s.visibleDevices(anon); len(got) != 0 {
|
||||||
|
t.Fatalf("an empty subject matched %d devices", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user