diff --git a/client/web/auth.go b/client/web/auth.go index 5112bfffe..c95cba1e9 100644 --- a/client/web/auth.go +++ b/client/web/auth.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "time" @@ -238,6 +239,7 @@ func (s *Server) newSessionID() (string, error) { // peer is allowed to edit via the web UI. // // map value is true if the peer can edit the given feature. +// Only capFeatures included in validCaps will be included. type peerCapabilities map[capFeature]bool // canEdit is true if the peerCapabilities grant edit access @@ -252,21 +254,47 @@ func (p peerCapabilities) canEdit(feature capFeature) bool { return p[feature] } +// isEmpty is true if p is either nil or has no capabilities +// with value true. +func (p peerCapabilities) isEmpty() bool { + if p == nil { + return true + } + for _, v := range p { + if v == true { + return false + } + } + return true +} + 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. + // + // IMPORTANT: When adding a new cap, also update validCaps slice below. - 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 + capFeatureAll capFeature = "*" // grants peer management of all features + capFeatureSSH capFeature = "ssh" // grants peer SSH server management + capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management + capFeatureExitNodes capFeature = "exitnodes" // 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 ) +// validCaps contains the list of valid capabilities used in the web client. +// Any capabilities included in a peer's grants that do not fall into this +// list will be ignored. +var validCaps []capFeature = []capFeature{ + capFeatureAll, + capFeatureSSH, + capFeatureSubnets, + capFeatureExitNodes, + capFeatureAccount, +} + type capRule struct { CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit } @@ -274,7 +302,13 @@ type capRule struct { // toPeerCapabilities parses out the web ui capabilities from the // given whois response. func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) { - if whois == nil { + if whois == nil || status == nil { + return peerCapabilities{}, nil + } + if whois.Node.IsTagged() { + // We don't allow management *from* tagged nodes, so ignore caps. + // The web client auth flow relies on having a true user identity + // that can be verified through login. return peerCapabilities{}, nil } @@ -295,7 +329,10 @@ func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) ( } for _, c := range rules { for _, f := range c.CanEdit { - caps[capFeature(strings.ToLower(f))] = true + cap := capFeature(strings.ToLower(f)) + if slices.Contains(validCaps, cap) { + caps[cap] = true + } } } return caps, nil diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 4a2f5818c..14ba0cd14 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -11,7 +11,7 @@ import LoginView from "src/components/views/login-view" import SSHView from "src/components/views/ssh-view" import SubnetRouterView from "src/components/views/subnet-router-view" import { UpdatingView } from "src/components/views/updating-view" -import useAuth, { AuthResponse } from "src/hooks/auth" +import useAuth, { AuthResponse, canEdit } from "src/hooks/auth" import { Feature, featureDescription, NodeData } from "src/types" import Card from "src/ui/card" import EmptyState from "src/ui/empty-state" @@ -56,16 +56,19 @@ function WebClient({
- + - + - + - + {/* Share local content */} diff --git a/client/web/src/components/login-toggle.tsx b/client/web/src/components/login-toggle.tsx index 793802153..f5c4efe3c 100644 --- a/client/web/src/components/login-toggle.tsx +++ b/client/web/src/components/login-toggle.tsx @@ -2,15 +2,17 @@ // SPDX-License-Identifier: BSD-3-Clause import cx from "classnames" -import React, { useCallback, useEffect, useState } from "react" +import React, { useCallback, useMemo, useState } from "react" import ChevronDown from "src/assets/icons/chevron-down.svg?react" import Eye from "src/assets/icons/eye.svg?react" import User from "src/assets/icons/user.svg?react" -import { AuthResponse, AuthType } from "src/hooks/auth" +import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth" +import { useTSWebConnected } from "src/hooks/ts-web-connected" import { NodeData } from "src/types" import Button from "src/ui/button" import Popover from "src/ui/popover" import ProfilePic from "src/ui/profile-pic" +import { assertNever, isHTTPS } from "src/utils/util" export default function LoginToggle({ node, @@ -22,12 +24,29 @@ export default function LoginToggle({ newSession: () => Promise }) { const [open, setOpen] = useState(false) + const { tsWebConnected, checkTSWebConnection } = useTSWebConnected( + auth.serverMode, + node.IPv4 + ) return ( + auth.serverMode === "readonly" ? ( + + ) : auth.serverMode === "login" ? ( + + ) : auth.serverMode === "manage" ? ( + + ) : ( + assertNever(auth.serverMode) + ) } side="bottom" align="end" @@ -35,231 +54,306 @@ export default function LoginToggle({ onOpenChange={setOpen} asChild > - {!auth.canManageNode ? ( - - ) : ( -
- -
- )} +
+ {auth.authorized ? ( + + ) : ( + + )} +
) } -function LoginPopoverContent({ +/** + * TriggerWhenManaging is displayed as the trigger for the login popover + * when the user has an active authorized managment session. + */ +function TriggerWhenManaging({ + auth, + open, + setOpen, +}: { + auth: AuthResponse + open: boolean + setOpen: (next: boolean) => void +}) { + return ( +
+ +
+ ) +} + +/** + * TriggerWhenReading is displayed as the trigger for the login popover + * when the user is currently in read mode (doesn't have an authorized + * management session). + */ +function TriggerWhenReading({ + auth, + open, + setOpen, +}: { + auth: AuthResponse + open: boolean + setOpen: (next: boolean) => void +}) { + return ( + + ) +} + +/** + * PopoverContentHeader is the header for the login popover. + */ +function PopoverContentHeader({ auth }: { auth: AuthResponse }) { + return ( +
+ {auth.authorized ? "Managing" : "Viewing"} + {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} +
+ ) +} + +/** + * PopoverContentFooter is the footer for the login popover. + */ +function PopoverContentFooter({ auth }: { auth: AuthResponse }) { + return auth.viewerIdentity ? ( + <> +
+
+ +

+ We recognize you because you are accessing this page from{" "} + + {auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP} + +

+
+ + ) : null +} + +/** + * ReadonlyModeContent is the body of the login popover when the web + * client is being run in "readonly" server mode. + */ +function ReadonlyModeContent({ auth }: { auth: AuthResponse }) { + return ( + <> + +

+ This web interface is running in read-only mode.{" "} + + Learn more → + +

+ + + ) +} + +/** + * LoginModeContent is the body of the login popover when the web + * client is being run in "login" server mode. + */ +function LoginModeContent({ node, auth, - newSession, + tsWebConnected, + checkTSWebConnection, }: { node: NodeData auth: AuthResponse - newSession: () => Promise + tsWebConnected: boolean + checkTSWebConnection: () => void }) { - /** - * canConnectOverTS indicates whether the current viewer - * is able to hit the node's web client that's being served - * at http://${node.IP}:5252. If false, this means that the - * viewer must connect to the correct tailnet before being - * able to sign in. - */ - const [canConnectOverTS, setCanConnectOverTS] = useState(false) - const [isRunningCheck, setIsRunningCheck] = useState(false) - - // Whether the current page is loaded over HTTPS. - // If it is, then the connectivity check to the management client - // will fail with a mixed-content error. - const isHTTPS = window.location.protocol === "https:" + const https = isHTTPS() + // We can't run the ts web connection test when the webpage is loaded + // over HTTPS. So in this case, we default to presenting a login button + // with some helper text reminding the user to check their connection + // themselves. + const hasACLAccess = https || tsWebConnected - const checkTSConnection = useCallback(() => { - if (auth.viewerIdentity || isHTTPS) { - // Skip the connectivity check if we either already know we're connected over Tailscale, - // or know the connectivity check will fail because the current page is loaded over HTTPS. - setCanConnectOverTS(true) - return + const hasEditCaps = useMemo(() => { + if (!auth.viewerIdentity) { + // If not connected to login client over tailscale, we won't know the viewer's + // identity. So we must assume they may be able to edit something and have the + // management client handle permissions once the user gets there. + return true } - // Otherwise, test connection to the ts IP. - if (isRunningCheck) { - return // already checking - } - setIsRunningCheck(true) - fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" }) - .then(() => { - setCanConnectOverTS(true) - setIsRunningCheck(false) - }) - .catch(() => setIsRunningCheck(false)) - }, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS]) - - /** - * Checking connection for first time on page load. - * - * While not connected, we check again whenever the mouse - * enters the popover component, to pick up on the user - * leaving to turn on Tailscale then returning to the view. - * See `onMouseEnter` on the div below. - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => checkTSConnection(), []) + return hasAnyEditCapabilities(auth) + }, [auth]) - const handleSignInClick = useCallback(() => { - if (auth.viewerIdentity && auth.serverMode === "manage") { - if (window.self !== window.top) { - // if we're inside an iframe, start session in new window - let url = new URL(window.location.href) - url.searchParams.set("check", "now") - window.open(url, "_blank") - } else { - newSession() - } + const handleLogin = useCallback(() => { + // Must be connected over Tailscale to log in. + // Send user to Tailscale IP and start check mode + const manageURL = `http://${node.IPv4}:5252/?check=now` + if (window.self !== window.top) { + // If we're inside an iframe, open management client in new window. + window.open(manageURL, "_blank") } else { - // Must be connected over Tailscale to log in. - // Send user to Tailscale IP and start check mode - const manageURL = `http://${node.IPv4}:5252/?check=now` - if (window.self !== window.top) { - // if we're inside an iframe, open management client in new window - window.open(manageURL, "_blank") - } else { - window.location.href = manageURL - } + window.location.href = manageURL } - }, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4]) + }, [node.IPv4]) return ( -
-
- {!auth.canManageNode ? "Viewing" : "Managing"} - {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} -
- {!auth.canManageNode && ( +
+ + {!hasACLAccess || !hasEditCaps ? ( <> - {auth.serverMode === "readonly" ? ( -

- This web interface is running in read-only mode.{" "} - - Learn more → - -

- ) : !auth.viewerIdentity ? ( - // User is not connected over Tailscale. - // These states are only possible on the login client. - <> - {!canConnectOverTS ? ( - <> -

- {!node.ACLAllowsAnyIncomingTraffic ? ( - // Tailnet ACLs don't allow access. - <> - The current tailnet policy file does not allow - connecting to this device. - - ) : ( - // ACLs allow access, but user can't connect. - <> - Cannot access this device’s Tailscale IP. Make sure you - are connected to your tailnet, and that your policy file - allows access. - - )}{" "} - - Learn more → - -

- - ) : ( - // User can connect to Tailcale IP; sign in when ready. - <> -

- You can see most of this device’s details. To make changes, - you need to sign in. -

- {isHTTPS && ( - // we don't know if the user can connect over TS, so - // provide extra tips in case they have trouble. -

- Make sure you are connected to your tailnet, and that your - policy file allows access. -

- )} - - - )} - - ) : auth.authNeeded === AuthType.tailscale ? ( - // User is connected over Tailscale, but needs to complete check mode. - <> -

- To make changes, sign in to confirm your identity. This extra - step helps us keep your device secure. -

- - - ) : ( - // User is connected over tailscale, but doesn't have permission to manage. -

- You don’t have permission to make changes to this device, but you - can view most of its details. -

- )} +

+ {!hasEditCaps ? ( + // ACLs allow access, but user isn't allowed to edit any features, + // restricted to readonly. No point in sending them over to the + // tailscaleIP:5252 address. + <> + You don’t have permission to make changes to this device, but + you can view most of its details. + + ) : !node.ACLAllowsAnyIncomingTraffic ? ( + // Tailnet ACLs don't allow access to anyone. + <> + The current tailnet policy file does not allow connecting to + this device. + + ) : ( + // ACLs don't allow access to this user specifically. + <> + Cannot access this device’s Tailscale IP. Make sure you are + connected to your tailnet, and that your policy file allows + access. + + )}{" "} + + Learn more → + +

- )} - {auth.viewerIdentity && ( + ) : ( + // User can connect to Tailcale IP; sign in when ready. <> -
-
- -

- We recognize you because you are accessing this page from{" "} - - {auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP} - +

+ You can see most of this device’s details. To make changes, you need + to sign in. +

+ {https && ( + // we don't know if the user can connect over TS, so + // provide extra tips in case they have trouble. +

+ Make sure you are connected to your tailnet, and that your policy + file allows access.

-
+ )} + )} +
) } +/** + * ManageModeContent is the body of the login popover when the web + * client is being run in "manage" server mode. + */ +function ManageModeContent({ + auth, + newSession, +}: { + node: NodeData + auth: AuthResponse + newSession: () => void +}) { + const handleLogin = useCallback(() => { + if (window.self !== window.top) { + // If we're inside an iframe, start session in new window. + let url = new URL(window.location.href) + url.searchParams.set("check", "now") + window.open(url, "_blank") + } else { + newSession() + } + }, [newSession]) + + const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth]) + + return ( + <> + + {!auth.authorized && + (hasAnyPermissions ? ( + // User is connected over Tailscale, but needs to complete check mode. + <> +

+ To make changes, sign in to confirm your identity. This extra step + helps us keep your device secure. +

+ + + ) : ( + // User is connected over tailscale, but doesn't have permission to manage. +

+ You don’t have permission to make changes to this device, but you + can view most of its details.{" "} + + Learn more → + +

+ ))} + + + ) +} + function SignInButton({ auth, onClick, diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index ec885d68e..fa58e52ae 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag" import * as Control from "src/components/control-components" import NiceIP from "src/components/nice-ip" import { UpdateAvailableNotification } from "src/components/update-available" +import { AuthResponse, canEdit } from "src/hooks/auth" import { NodeData } from "src/types" import Button from "src/ui/button" import Card from "src/ui/card" @@ -16,11 +17,11 @@ import QuickCopy from "src/ui/quick-copy" import { useLocation } from "wouter" export default function DeviceDetailsView({ - readonly, node, + auth, }: { - readonly: boolean node: NodeData + auth: AuthResponse }) { return ( <> @@ -37,11 +38,11 @@ export default function DeviceDetailsView({ })} />
- {!readonly && } + {canEdit("account", auth) && } {node.Features["auto-update"] && - !readonly && + canEdit("account", auth) && node.ClientVersion && !node.ClientVersion.RunningLatest && ( diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 8c718f265..807382346 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -8,17 +8,18 @@ import ArrowRight from "src/assets/icons/arrow-right.svg?react" import Machine from "src/assets/icons/machine.svg?react" import AddressCard from "src/components/address-copy-card" import ExitNodeSelector from "src/components/exit-node-selector" +import { AuthResponse, canEdit } from "src/hooks/auth" import { NodeData } from "src/types" import Card from "src/ui/card" import { pluralize } from "src/utils/util" import { Link, useLocation } from "wouter" export default function HomeView({ - readonly, node, + auth, }: { - readonly: boolean node: NodeData + auth: AuthResponse }) { const [allSubnetRoutes, pendingSubnetRoutes] = useMemo( () => [ @@ -63,7 +64,11 @@ export default function HomeView({ {(node.Features["advertise-exit-node"] || node.Features["use-exit-node"]) && ( - + )} () const [loading, setLoading] = useState(true) @@ -33,18 +58,16 @@ export default function useAuth() { return apiFetch("/auth", "GET") .then((d) => { setData(d) - switch (d.authNeeded) { - case AuthType.synology: - fetch("/webman/login.cgi") - .then((r) => r.json()) - .then((a) => { - setSynoToken(a.SynoToken) - setRanSynoAuth(true) - setLoading(false) - }) - break - default: - setLoading(false) + if (d.needsSynoAuth) { + fetch("/webman/login.cgi") + .then((r) => r.json()) + .then((a) => { + setSynoToken(a.SynoToken) + setRanSynoAuth(true) + setLoading(false) + }) + } else { + setLoading(false) } return d }) @@ -72,8 +95,13 @@ export default function useAuth() { useEffect(() => { loadAuth().then((d) => { + if (!d) { + return + } if ( - !d?.canManageNode && + !d.authorized && + hasAnyEditCapabilities(d) && + // Start auth flow immediately if browser has requested it. new URLSearchParams(window.location.search).get("check") === "now" ) { newSession() diff --git a/client/web/src/hooks/ts-web-connected.ts b/client/web/src/hooks/ts-web-connected.ts new file mode 100644 index 000000000..3145663d7 --- /dev/null +++ b/client/web/src/hooks/ts-web-connected.ts @@ -0,0 +1,46 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import { useCallback, useEffect, useState } from "react" +import { isHTTPS } from "src/utils/util" +import { AuthServerMode } from "./auth" + +/** + * useTSWebConnected hook is used to check whether the browser is able to + * connect to the web client served at http://${nodeIPv4}:5252 + */ +export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) { + const [tsWebConnected, setTSWebConnected] = useState( + mode === "manage" // browser already on the web client + ) + const [isLoading, setIsLoading] = useState(false) + + const checkTSWebConnection = useCallback(() => { + if (mode === "manage") { + // Already connected to the web client. + setTSWebConnected(true) + return + } + if (isHTTPS()) { + // When page is loaded over HTTPS, the connectivity check will always + // fail with a mixed-content error. In this case don't bother doing + // the check. + return + } + if (isLoading) { + return // already checking + } + setIsLoading(true) + fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" }) + .then(() => { + setTSWebConnected(true) + setIsLoading(false) + }) + .catch(() => setIsLoading(false)) + }, [isLoading, mode, nodeIPv4]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load + + return { tsWebConnected, checkTSWebConnection, isLoading } +} diff --git a/client/web/src/utils/util.ts b/client/web/src/utils/util.ts index e3bee9d1d..5f8eda7b7 100644 --- a/client/web/src/utils/util.ts +++ b/client/web/src/utils/util.ts @@ -49,3 +49,10 @@ export function isPromise(val: unknown): val is Promise { } return typeof val === "object" && "then" in val } + +/** + * isHTTPS reports whether the current page is loaded over HTTPS. + */ +export function isHTTPS() { + return window.location.protocol === "https:" +} diff --git a/client/web/web.go b/client/web/web.go index 3ec9ebd64..b0aefc589 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -568,9 +568,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { return case path == "/routes" && r.Method == httpm.POST: peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool { - if d.SetExitNode && !p.canEdit(capFeatureExitNode) { + if d.SetExitNode && !p.canEdit(capFeatureExitNodes) { return false - } else if d.SetRoutes && !p.canEdit(capFeatureSubnet) { + } else if d.SetRoutes && !p.canEdit(capFeatureSubnets) { return false } return true @@ -622,18 +622,11 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid endpoint", http.StatusNotFound) } -type authType string - -var ( - synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls - tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode -) - type authResponse struct { - AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth - CanManageNode bool `json:"canManageNode"` - ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"` ServerMode ServerMode `json:"serverMode"` + Authorized bool `json:"authorized"` // has an authorized management session + ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"` + NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"` } // viewerIdentity is the Tailscale identity of the source node @@ -652,9 +645,11 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { var resp authResponse resp.ServerMode = s.mode session, whois, status, sErr := s.getSession(r) + var caps peerCapabilities if whois != nil { - caps, err := toPeerCapabilities(status, whois) + var err error + caps, err = toPeerCapabilities(status, whois) if err != nil { http.Error(w, sErr.Error(), http.StatusInternalServerError) return @@ -681,7 +676,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { return } if !authorized { - resp.AuthNeeded = synoAuth + resp.NeedsSynoAuth = true writeJSON(w, resp) return } @@ -697,21 +692,17 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { switch { case sErr != nil && errors.Is(sErr, errNotUsingTailscale): - // Restricted to the readonly view, no auth action to take. s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1) - resp.AuthNeeded = "" + resp.Authorized = false // restricted to the readonly view case sErr != nil && errors.Is(sErr, errNotOwner): - // Restricted to the readonly view, no auth action to take. s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1) - resp.AuthNeeded = "" + resp.Authorized = false // restricted to the readonly view case sErr != nil && errors.Is(sErr, errTaggedLocalSource): - // Restricted to the readonly view, no auth action to take. s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1) - resp.AuthNeeded = "" + resp.Authorized = false // restricted to the readonly view case sErr != nil && errors.Is(sErr, errTaggedRemoteSource): - // Restricted to the readonly view, no auth action to take. s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1) - resp.AuthNeeded = "" + resp.Authorized = false // restricted to the readonly view case sErr != nil && !errors.Is(sErr, errNoSession): // Any other error. http.Error(w, sErr.Error(), http.StatusInternalServerError) @@ -722,16 +713,26 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) { } else { s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1) } - resp.CanManageNode = true - resp.AuthNeeded = "" + // User has a valid session. They're now authorized to edit if they + // have any edit capabilities. In practice, they won't be sent through + // the auth flow if they don't have edit caps, but their ACL granted + // permissions may change at any time. The frontend views and backend + // endpoints are always restricted to their current capabilities in + // addition to a valid session. + // + // But, we also check the caps here for a better user experience on + // the frontend login toggle, which uses resp.Authorized to display + // "viewing" vs "managing" copy. If they don't have caps, we want to + // display "viewing" even if they have a valid session. + resp.Authorized = !caps.isEmpty() default: - // whois being nil implies local as the request did not come over Tailscale if whois == nil || (whois.Node.StableID == status.Self.ID) { + // whois being nil implies local as the request did not come over Tailscale. s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1) } else { s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1) } - resp.AuthNeeded = tailscaleAuth + resp.Authorized = false // not yet authorized } writeJSON(w, resp) diff --git a/client/web/web_test.go b/client/web/web_test.go index 7288b8fa8..3c5543c12 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -622,7 +622,7 @@ func TestServeAuth(t *testing.T) { name: "no-session", path: "/api/auth", wantStatus: http.StatusOK, - wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode}, + wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, wantNewCookie: false, wantSession: nil, }, @@ -647,7 +647,7 @@ func TestServeAuth(t *testing.T) { path: "/api/auth", cookie: successCookie, wantStatus: http.StatusOK, - wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode}, + wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, @@ -695,7 +695,7 @@ func TestServeAuth(t *testing.T) { path: "/api/auth", cookie: successCookie, wantStatus: http.StatusOK, - wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode}, + wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode}, wantSession: &browserSession{ ID: successCookie, SrcNode: remoteNode.Node.ID, @@ -1219,9 +1219,10 @@ func TestPeerCapabilities(t *testing.T) { status: userOwnedStatus, whois: &apitype.WhoIsResponse{ UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)}, + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnet\"]}", + "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, @@ -1232,9 +1233,10 @@ func TestPeerCapabilities(t *testing.T) { status: userOwnedStatus, whois: &apitype.WhoIsResponse{ UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)}, + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnet\"]}", + "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, @@ -1244,6 +1246,7 @@ func TestPeerCapabilities(t *testing.T) { name: "tag-owned-no-webui-caps", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{}, }, @@ -1254,68 +1257,71 @@ func TestPeerCapabilities(t *testing.T) { name: "tag-owned-one-webui-cap", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnet\"]}", + "{\"canEdit\":[\"ssh\",\"subnets\"]}", }, }, }, wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnet: true, + capFeatureSSH: true, + capFeatureSubnets: true, }, }, { name: "tag-owned-multiple-webui-cap", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"ssh\",\"subnet\"]}", - "{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}", + "{\"canEdit\":[\"ssh\",\"subnets\"]}", + "{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}", }, }, }, wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnet: true, - capFeatureExitNode: true, - capFeatureAll: true, + capFeatureSSH: true, + capFeatureSubnets: true, + capFeatureExitNodes: true, + capFeatureAll: true, }, }, { name: "tag-owned-case-insensitive-caps", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ - "{\"canEdit\":[\"SSH\",\"sUBnet\"]}", + "{\"canEdit\":[\"SSH\",\"sUBnets\"]}", }, }, }, wantCaps: peerCapabilities{ - capFeatureSSH: true, - capFeatureSubnet: true, + capFeatureSSH: true, + capFeatureSubnets: true, }, }, { - name: "tag-owned-random-canEdit-contents-dont-error", + name: "tag-owned-random-canEdit-contents-get-dropped", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canEdit\":[\"unknown-feature\"]}", }, }, }, - wantCaps: peerCapabilities{ - "unknown-feature": true, - }, + wantCaps: peerCapabilities{}, }, { name: "tag-owned-no-canEdit-section", status: tagOwnedStatus, whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1)}, CapMap: tailcfg.PeerCapMap{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ "{\"canDoSomething\":[\"*\"]}", @@ -1324,6 +1330,19 @@ func TestPeerCapabilities(t *testing.T) { }, wantCaps: peerCapabilities{}, }, + { + name: "tagged-source-caps-ignored", + status: tagOwnedStatus, + whois: &apitype.WhoIsResponse{ + Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()}, + CapMap: tailcfg.PeerCapMap{ + tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ + "{\"canEdit\":[\"ssh\",\"subnets\"]}", + }, + }, + }, + wantCaps: peerCapabilities{}, + }, } for _, tt := range toPeerCapsTests { t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) { @@ -1347,36 +1366,33 @@ func TestPeerCapabilities(t *testing.T) { name: "empty-caps", caps: nil, wantCanEdit: map[capFeature]bool{ - capFeatureAll: false, - capFeatureFunnel: false, - capFeatureSSH: false, - capFeatureSubnet: false, - capFeatureExitNode: false, - capFeatureAccount: false, + capFeatureAll: false, + capFeatureSSH: false, + capFeatureSubnets: false, + capFeatureExitNodes: 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, + capFeatureAll: false, + capFeatureSSH: true, + capFeatureSubnets: false, + capFeatureExitNodes: 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, + capFeatureAll: true, + capFeatureSSH: true, + capFeatureSubnets: true, + capFeatureExitNodes: true, + capFeatureAccount: true, }, }, }