diff --git a/golink.go b/golink.go index 51b892c..e2f71d1 100644 --- a/golink.go +++ b/golink.go @@ -17,7 +17,6 @@ import ( "fmt" "html/template" "io/fs" - "io/ioutil" "log" "net" "net/http" @@ -35,6 +34,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/hostinfo" "tailscale.com/ipn" + "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -88,7 +88,7 @@ func Run() error { if *sqlitefile == "" { if devMode() { - tmpdir, err := ioutil.TempDir("", "golink_dev_*") + tmpdir, err := os.MkdirTemp("", "golink_dev_*") if err != nil { return err } @@ -396,8 +396,8 @@ func serveGo(w http.ResponseWriter, r *http.Request) { stats.dirty[link.Short]++ stats.mu.Unlock() - login, _ := currentUser(r) - env := expandEnv{Now: time.Now().UTC(), Path: remainder, user: login, query: r.URL.Query()} + cu, _ := currentUser(r) + env := expandEnv{Now: time.Now().UTC(), Path: remainder, user: cu.login, query: r.URL.Query()} target, err := expandLink(link.Long, env) if err != nil { log.Printf("expanding %q: %v", link.Long, err) @@ -446,21 +446,24 @@ func serveDetail(w http.ResponseWriter, r *http.Request) { return } - login, err := currentUser(r) + cu, err := currentUser(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + canEdit := canEditLink(r.Context(), link, cu) ownerExists, err := userExists(r.Context(), link.Owner) if err != nil { log.Printf("looking up tailnet user %q: %v", link.Owner, err) } - data := detailData{Link: link} - if link.Owner == login || !ownerExists { - data.Editable = true - data.Link.Owner = login - data.XSRF = xsrftoken.Generate(xsrfKey, login, short) + data := detailData{ + Link: link, + Editable: canEdit, + XSRF: xsrftoken.Generate(xsrfKey, cu.login, short), + } + if canEdit && !ownerExists { + data.Link.Owner = cu.login } detailTmpl.Execute(w, data) @@ -541,24 +544,42 @@ func expandLink(long string, env expandEnv) (*url.URL, error) { func devMode() bool { return *dev != "" } +const peerCapName = "tailscale.com/golink" + +type capabilities struct { + Admin bool `json:"admin"` +} + +type user struct { + login string + isAdmin bool +} + // currentUser returns the Tailscale user associated with the request. // In most cases, this will be the user that owns the device that made the request. // For tagged devices, the value "tagged-devices" is returned. // If the user can't be determined (such as requests coming through a subnet router), // an error is returned unless the -allow-unknown-users flag is set. -var currentUser = func(r *http.Request) (string, error) { +var currentUser = func(r *http.Request) (u user, err error) { if devMode() { - return "foo@example.com", nil + return user{login: "foo@example.com"}, nil } whois, err := localClient.WhoIs(r.Context(), r.RemoteAddr) if err != nil { if *allowUnknownUsers { // Don't report the error if we are allowing unknown users. - return "", nil + return u, nil + } + return u, err + } + u.login = whois.UserProfile.LoginName + caps, _ := tailcfg.UnmarshalCapJSON[capabilities](whois.CapMap, peerCapName) + for _, cap := range caps { + if cap.Admin { + u.isAdmin = true } - return "", err } - return whois.UserProfile.LoginName, nil + return u, nil } // userExists returns whether a user exists with the specified login in the current tailnet. @@ -597,7 +618,7 @@ func serveDelete(w http.ResponseWriter, r *http.Request) { return } - login, err := currentUser(r) + cu, err := currentUser(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -609,12 +630,12 @@ func serveDelete(w http.ResponseWriter, r *http.Request) { return } - if err := checkLinkOwnership(r.Context(), link, login); err != nil { - http.Error(w, fmt.Sprintf("cannot delete link: %v", err), http.StatusForbidden) + if !canEditLink(r.Context(), link, cu) { + http.Error(w, fmt.Sprintf("cannot delete link owned by %q", link.Owner), http.StatusForbidden) return } - if !xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, login, short) { + if !xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, cu.login, short) { http.Error(w, "invalid XSRF token", http.StatusBadRequest) return } @@ -646,7 +667,7 @@ func serveSave(w http.ResponseWriter, r *http.Request) { return } - login, err := currentUser(r) + cu, err := currentUser(r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -657,8 +678,8 @@ func serveSave(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) } - if err := checkLinkOwnership(r.Context(), link, login); err != nil { - http.Error(w, fmt.Sprintf("cannot update link: %v", err), http.StatusForbidden) + if !canEditLink(r.Context(), link, cu) { + http.Error(w, fmt.Sprintf("cannot update link owned by %q", link.Owner), http.StatusForbidden) return } @@ -674,7 +695,7 @@ func serveSave(w http.ResponseWriter, r *http.Request) { return } } else { - owner = login + owner = cu.login } now := time.Now().UTC() @@ -701,21 +722,25 @@ func serveSave(w http.ResponseWriter, r *http.Request) { } } -func checkLinkOwnership(ctx context.Context, link *Link, login string) error { +// canEditLink returns whether the specified user has permission to edit link. +// Admin users can edit all links. +// Non-admin users can only edit their own links or links without an active owner. +func canEditLink(ctx context.Context, link *Link, u user) bool { if link == nil || link.Owner == "" { - return nil + // new or unowned link + return true } - linkOwnerExists, err := userExists(ctx, link.Owner) + if u.isAdmin || link.Owner == u.login { + return true + } + + owned, err := userExists(ctx, link.Owner) if err != nil { log.Printf("looking up tailnet user %q: %v", link.Owner, err) } - // Don't allow deleting or updating links if the owner account still exists - // or if we're unsure because an error occurred. - if (linkOwnerExists && link.Owner != login) || err != nil { - return fmt.Errorf("link owned by user %q", link.Owner) - } - return nil + // Allow editing if the link is currently unowned + return err == nil && !owned } // serveExport prints a snapshot of the link database. Links are JSON encoded diff --git a/golink_test.go b/golink_test.go index 14a6006..e67d502 100644 --- a/golink_test.go +++ b/golink_test.go @@ -34,7 +34,7 @@ func TestServeGo(t *testing.T) { tests := []struct { name string link string - currentUser func(*http.Request) (string, error) + currentUser func(*http.Request) (user, error) wantStatus int wantLink string }{ @@ -47,7 +47,7 @@ func TestServeGo(t *testing.T) { { name: "simple link, anonymous request", link: "/who", - currentUser: func(*http.Request) (string, error) { return "", nil }, + currentUser: func(*http.Request) (user, error) { return user{}, nil }, wantStatus: http.StatusFound, wantLink: "http://who/", }, @@ -88,7 +88,7 @@ func TestServeGo(t *testing.T) { { name: "user link, anonymous request", link: "/me", - currentUser: func(*http.Request) (string, error) { return "", nil }, + currentUser: func(*http.Request) (user, error) { return user{}, nil }, wantStatus: http.StatusUnauthorized, }, } @@ -131,7 +131,7 @@ func TestServeSave(t *testing.T) { short string long string allowUnknownUsers bool - currentUser func(*http.Request) (string, error) + currentUser func(*http.Request) (user, error) wantStatus int }{ { @@ -156,21 +156,28 @@ func TestServeSave(t *testing.T) { name: "disallow editing another's link", short: "who", long: "http://who/", - currentUser: func(*http.Request) (string, error) { return "bar@example.com", nil }, + currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com"}, nil }, wantStatus: http.StatusForbidden, }, { name: "allow editing link owned by tagged-devices", short: "link-owned-by-tagged-devices", long: "/after", - currentUser: func(*http.Request) (string, error) { return "bar@example.com", nil }, + currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com"}, nil }, + wantStatus: http.StatusOK, + }, + { + name: "admins can edit any link", + short: "who", + long: "http://who/", + currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com", isAdmin: true}, nil }, wantStatus: http.StatusOK, }, { name: "disallow unknown users", short: "who2", long: "http://who/", - currentUser: func(*http.Request) (string, error) { return "", errors.New("") }, + currentUser: func(*http.Request) (user, error) { return user{}, errors.New("") }, wantStatus: http.StatusInternalServerError, }, { @@ -178,7 +185,7 @@ func TestServeSave(t *testing.T) { short: "who2", long: "http://who/", allowUnknownUsers: true, - currentUser: func(*http.Request) (string, error) { return "", nil }, + currentUser: func(*http.Request) (user, error) { return user{}, nil }, wantStatus: http.StatusOK, }, } @@ -230,7 +237,7 @@ func TestServeDelete(t *testing.T) { name string short string xsrf string - currentUser func(*http.Request) (string, error) + currentUser func(*http.Request) (user, error) wantStatus int }{ { @@ -254,6 +261,13 @@ func TestServeDelete(t *testing.T) { xsrf: xsrf("link-owned-by-tagged-devices"), wantStatus: http.StatusOK, }, + { + name: "admin can delete unowned link", + short: "a", + currentUser: func(*http.Request) (user, error) { return user{login: "foo@example.com", isAdmin: true}, nil }, + xsrf: xsrf("a"), + wantStatus: http.StatusOK, + }, { name: "invalid xsrf", short: "foo", @@ -284,7 +298,7 @@ func TestServeDelete(t *testing.T) { r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() serveDelete(w, r) - + t.Logf("response body: %v", w.Body.String()) if w.Code != tt.wantStatus { t.Errorf("serveDelete(%q) = %d; want %d", tt.short, w.Code, tt.wantStatus) }