Răsfoiți Sursa

client/web: add initial types for using peer capabilities

Sets up peer capability types for future use within the web client
views and APIs.

Updates tailscale/corp#16695

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy 2 ani în urmă
părinte
comite
331a6d105f
4 a modificat fișierele cu 225 adăugiri și 4 ștergeri
  1. 53 0
      client/web/auth.go
  2. 11 4
      client/web/web.go
  3. 158 0
      client/web/web_test.go
  4. 3 0
      tailcfg/tailcfg.go

+ 53 - 0
client/web/auth.go

@@ -8,6 +8,7 @@ import (
 	"crypto/rand"
 	"encoding/base64"
 	"errors"
+	"fmt"
 	"net/http"
 	"net/url"
 	"strings"
@@ -232,3 +233,55 @@ func (s *Server) newSessionID() (string, error) {
 	}
 	return "", errors.New("too many collisions generating new session; please refresh page")
 }
+
+type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature
+
+// canEdit is true if the peerCapabilities grant edit access
+// to the given feature.
+func (p peerCapabilities) canEdit(feature capFeature) bool {
+	if p == nil {
+		return false
+	}
+	if p[capFeatureAll] {
+		return true
+	}
+	return p[feature]
+}
+
+type capFeature string
+
+const (
+	// The following values should not be edited.
+	// New caps can be added, but existing ones should not be changed,
+	// as these exact values are used by users in tailnet policy files.
+
+	capFeatureAll      capFeature = "*"        // grants peer management of all features
+	capFeatureFunnel   capFeature = "funnel"   // grants peer serve/funnel management
+	capFeatureSSH      capFeature = "ssh"      // grants peer SSH server management
+	capFeatureSubnet   capFeature = "subnet"   // grants peer subnet routes management
+	capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes
+	capFeatureAccount  capFeature = "account"  // grants peer ability to turn on auto updates and log out of node
+)
+
+type capRule struct {
+	CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
+}
+
+// toPeerCapabilities parses out the web ui capabilities from the
+// given whois response.
+func toPeerCapabilities(whois *apitype.WhoIsResponse) (peerCapabilities, error) {
+	caps := peerCapabilities{}
+	if whois == nil {
+		return caps, nil
+	}
+	rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
+	if err != nil {
+		return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
+	}
+	for _, c := range rules {
+		for _, f := range c.CanEdit {
+			caps[capFeature(strings.ToLower(f))] = true
+		}
+	}
+	return caps, nil
+}

+ 11 - 4
client/web/web.go

@@ -450,10 +450,11 @@ type authResponse struct {
 // viewerIdentity is the Tailscale identity of the source node
 // connected to this web client.
 type viewerIdentity struct {
-	LoginName     string `json:"loginName"`
-	NodeName      string `json:"nodeName"`
-	NodeIP        string `json:"nodeIP"`
-	ProfilePicURL string `json:"profilePicUrl,omitempty"`
+	LoginName     string           `json:"loginName"`
+	NodeName      string           `json:"nodeName"`
+	NodeIP        string           `json:"nodeIP"`
+	ProfilePicURL string           `json:"profilePicUrl,omitempty"`
+	Capabilities  peerCapabilities `json:"capabilities"` // features peer is allowed to edit
 }
 
 // serverAPIAuth handles requests to the /api/auth endpoint
@@ -464,10 +465,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
 	session, whois, status, sErr := s.getSession(r)
 
 	if whois != nil {
+		caps, err := toPeerCapabilities(whois)
+		if err != nil {
+			http.Error(w, sErr.Error(), http.StatusInternalServerError)
+			return
+		}
 		resp.ViewerIdentity = &viewerIdentity{
 			LoginName:     whois.UserProfile.LoginName,
 			NodeName:      whois.Node.Name,
 			ProfilePicURL: whois.UserProfile.ProfilePicURL,
+			Capabilities:  caps,
 		}
 		if addrs := whois.Node.Addresses; len(addrs) > 0 {
 			resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()

+ 158 - 0
client/web/web_test.go

@@ -450,6 +450,7 @@ func TestServeAuth(t *testing.T) {
 		NodeName:      remoteNode.Node.Name,
 		NodeIP:        remoteIP,
 		ProfilePicURL: user.ProfilePicURL,
+		Capabilities:  peerCapabilities{},
 	}
 
 	testControlURL := &defaultControlURL
@@ -1097,6 +1098,163 @@ func TestRequireTailscaleIP(t *testing.T) {
 	}
 }
 
+func TestPeerCapabilities(t *testing.T) {
+	// Testing web.toPeerCapabilities
+	toPeerCapsTests := []struct {
+		name     string
+		whois    *apitype.WhoIsResponse
+		wantCaps peerCapabilities
+	}{
+		{
+			name:     "empty-whois",
+			whois:    nil,
+			wantCaps: peerCapabilities{},
+		},
+		{
+			name: "no-webui-caps",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
+				},
+			},
+			wantCaps: peerCapabilities{},
+		},
+		{
+			name: "one-webui-cap",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
+						"{\"canEdit\":[\"ssh\",\"subnet\"]}",
+					},
+				},
+			},
+			wantCaps: peerCapabilities{
+				capFeatureSSH:    true,
+				capFeatureSubnet: true,
+			},
+		},
+		{
+			name: "multiple-webui-cap",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
+						"{\"canEdit\":[\"ssh\",\"subnet\"]}",
+						"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
+					},
+				},
+			},
+			wantCaps: peerCapabilities{
+				capFeatureSSH:      true,
+				capFeatureSubnet:   true,
+				capFeatureExitNode: true,
+				capFeatureAll:      true,
+			},
+		},
+		{
+			name: "case=insensitive-caps",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
+						"{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
+					},
+				},
+			},
+			wantCaps: peerCapabilities{
+				capFeatureSSH:    true,
+				capFeatureSubnet: true,
+			},
+		},
+		{
+			name: "random-canEdit-contents-dont-error",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
+						"{\"canEdit\":[\"unknown-feature\"]}",
+					},
+				},
+			},
+			wantCaps: peerCapabilities{
+				"unknown-feature": true,
+			},
+		},
+		{
+			name: "no-canEdit-section",
+			whois: &apitype.WhoIsResponse{
+				CapMap: tailcfg.PeerCapMap{
+					tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
+						"{\"canDoSomething\":[\"*\"]}",
+					},
+				},
+			},
+			wantCaps: peerCapabilities{},
+		},
+	}
+	for _, tt := range toPeerCapsTests {
+		t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
+			got, err := toPeerCapabilities(tt.whois)
+			if err != nil {
+				t.Fatalf("unexpected: %v", err)
+			}
+			if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
+				t.Errorf("wrong caps; (-got+want):%v", diff)
+			}
+		})
+	}
+
+	// Testing web.peerCapabilities.canEdit
+	canEditTests := []struct {
+		name        string
+		caps        peerCapabilities
+		wantCanEdit map[capFeature]bool
+	}{
+		{
+			name: "empty-caps",
+			caps: nil,
+			wantCanEdit: map[capFeature]bool{
+				capFeatureAll:      false,
+				capFeatureFunnel:   false,
+				capFeatureSSH:      false,
+				capFeatureSubnet:   false,
+				capFeatureExitNode: false,
+				capFeatureAccount:  false,
+			},
+		},
+		{
+			name: "some-caps",
+			caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
+			wantCanEdit: map[capFeature]bool{
+				capFeatureAll:      false,
+				capFeatureFunnel:   false,
+				capFeatureSSH:      true,
+				capFeatureSubnet:   false,
+				capFeatureExitNode: false,
+				capFeatureAccount:  true,
+			},
+		},
+		{
+			name: "wildcard-in-caps",
+			caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
+			wantCanEdit: map[capFeature]bool{
+				capFeatureAll:      true,
+				capFeatureFunnel:   true,
+				capFeatureSSH:      true,
+				capFeatureSubnet:   true,
+				capFeatureExitNode: true,
+				capFeatureAccount:  true,
+			},
+		},
+	}
+	for _, tt := range canEditTests {
+		t.Run("canEdit-"+tt.name, func(t *testing.T) {
+			for f, want := range tt.wantCanEdit {
+				if got := tt.caps.canEdit(f); got != want {
+					t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
+				}
+			}
+		})
+	}
+}
+
 var (
 	defaultControlURL   = "https://controlplane.tailscale.com"
 	testAuthPath        = "/a/12345"

+ 3 - 0
tailcfg/tailcfg.go

@@ -1341,6 +1341,9 @@ const (
 	PeerCapabilityWakeOnLAN PeerCapability = "https://tailscale.com/cap/wake-on-lan"
 	// PeerCapabilityIngress grants the ability for a peer to send ingress traffic.
 	PeerCapabilityIngress PeerCapability = "https://tailscale.com/cap/ingress"
+	// PeerCapabilityWebUI grants the ability for a peer to edit features from the
+	// device Web UI.
+	PeerCapabilityWebUI PeerCapability = "tailscale.com/cap/webui"
 )
 
 // NodeCapMap is a map of capabilities to their optional values. It is valid for