drive: add taildrive "magic" share with name-encoded ACLs

Reserves the share name "magic" for a share whose top-level directory
names encode their own ACL (e.g. "fserb+rhea"). Peers only see and can
descend into top-level dirs that name both the sharer and themselves.
Top-level dir creation/deletion via WebDAV is denied for remote peers;
the directory layout is sharer-local only.

New package drive/magic parses and matches the directory names. The
peerapi handler plumbs the peer's and the local node's tailnet logins
into a new drive.Authz value passed to ServeHTTPWithPerms. The CLI
recognizes the reserved name and validates the path is a directory.

Change-Id: If7b6ad9fdab46b99e7ac5a7c5417a57d61b44478
This commit is contained in:
Fernando Serboncini 2026-04-27 09:33:20 -04:00
parent 3a05c450ce
commit 02631749ba
16 changed files with 767 additions and 18 deletions

View File

@ -8,11 +8,13 @@ package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/drive"
"tailscale.com/feature/buildfeatures"
)
const (
@ -80,10 +82,27 @@ func runDriveShare(ctx context.Context, args []string) error {
return err
}
err = localClient.DriveShareSet(ctx, &drive.Share{
share := &drive.Share{
Name: name,
Path: absolutePath,
})
}
// "magic" is a reserved share name (drive.MagicShareName). Magic shares
// are treated specially server-side: each top-level directory inside the
// share encodes its own ACL (see package drive/magic). The path must
// already exist and be a directory; for non-magic shares we leave that
// check to the backend.
if buildfeatures.HasDriveMagic && share.IsMagic() {
fi, err := os.Stat(absolutePath)
if err != nil {
return fmt.Errorf("magic share path: %w", err)
}
if !fi.IsDir() {
return fmt.Errorf("magic share path %q is not a directory", absolutePath)
}
}
err = localClient.DriveShareSet(ctx, share)
if err == nil {
fmt.Printf("Sharing %q as %q\n", path, name)
}
@ -144,11 +163,19 @@ func runDriveList(ctx context.Context, args []string) error {
longestAs = len(share.As)
}
}
formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath)
fmt.Printf(formatString, "name", "path", "as")
fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs))
formatString := fmt.Sprintf("%%-%ds %%-%ds %%-%ds %%s\n", longestName, longestPath, longestAs)
fmt.Printf(formatString, "name", "path", "as", "type")
fmt.Printf(formatString,
strings.Repeat("-", longestName),
strings.Repeat("-", longestPath),
strings.Repeat("-", longestAs),
"----")
for _, share := range shares {
fmt.Printf(formatString, share.Name, share.Path, share.As)
typ := "normal"
if buildfeatures.HasDriveMagic && share.IsMagic() {
typ = "magic"
}
fmt.Printf(formatString, share.Name, share.Path, share.As, typ)
}
return nil

View File

@ -93,6 +93,17 @@ func TestOmitDrive(t *testing.T) {
}.Check(t)
}
func TestOmitDriveMagic(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_drive_magic,ts_include_cli",
BadDeps: map[string]string{
"tailscale.com/drive/magic": "unexpected dep with ts_omit_drive_magic",
},
}.Check(t)
}
func TestOmitPortmapper(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",

View File

@ -438,6 +438,8 @@ type remote struct {
fileServer *FileServer
shares map[string]string
permissions map[string]drive.Permission
peerLogin string
sharerLogin string
mu sync.RWMutex
}
@ -452,7 +454,11 @@ func (r *remote) unfreeze() {
func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.RLock()
defer r.mu.RUnlock()
r.fs.ServeHTTPWithPerms(r.permissions, w, req)
r.fs.ServeHTTPWithPerms(drive.Authz{
PeerLogin: r.peerLogin,
SharerLogin: r.sharerLogin,
Permissions: r.permissions,
}, w, req)
}
type system struct {
@ -745,7 +751,7 @@ func pathTo(remote, share, name string) string {
type noopAuthorizer struct{}
func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) {
return &noopAuthenticator{}, nil
return &noopAuthenticator{}, body
}
func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) {

View File

@ -0,0 +1,220 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive_magic
package driveimpl
import (
"os"
"path"
"path/filepath"
"slices"
"sort"
"testing"
"tailscale.com/drive"
)
// addMagicShare configures the named remote to expose a magic share.
// The share's on-disk path is returned so the caller can prepopulate
// ACL directories.
func (s *system) addMagicShare(remoteName, shareName, peerLogin, sharerLogin string) string {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
folder := s.t.TempDir()
r.shares[shareName] = folder
r.permissions[shareName] = drive.PermissionReadWrite
r.peerLogin = peerLogin
r.sharerLogin = sharerLogin
shares := make([]*drive.Share, 0, len(r.shares))
for n, f := range r.shares {
shares = append(shares, &drive.Share{Name: n, Path: f})
}
slices.SortFunc(shares, drive.CompareShares)
r.fs.SetShares(shares)
r.fileServer.SetShares(r.shares)
return folder
}
// setPeer rebinds the peer/sharer login for the given remote. The remote's
// ServeHTTP picks up these values on each request, so this works for tests
// that simulate different peer identities against the same sharer.
func (s *system) setPeer(remoteName, peerLogin, sharerLogin string) {
r, ok := s.remotes[remoteName]
if !ok {
s.t.Fatalf("unknown remote %q", remoteName)
}
r.mu.Lock()
r.peerLogin = peerLogin
r.sharerLogin = sharerLogin
r.mu.Unlock()
}
func mkdir(t *testing.T, parent, name string) {
t.Helper()
if err := os.Mkdir(filepath.Join(parent, name), 0755); err != nil {
t.Fatalf("mkdir %q: %v", name, err)
}
}
func TestMagicShareDiscoveryAndListing(t *testing.T) {
const (
magicShare = "magic"
sharerLogin = "fserb@example.com"
peerRhea = "rhea@example.com"
peerJoe = "joe@example.com"
peerStranger = "stranger@example.com"
dirSharerOnly = "fserb"
dirSharerRhea = "fserb+rhea"
dirNoSharer = "rhea+joe" // invalid: no sharer
dirGarbage = "not a name" // invalid grammar
)
s := newSystem(t)
s.addRemote(remote1)
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
mkdir(t, folder, dirSharerOnly)
mkdir(t, folder, dirSharerRhea)
mkdir(t, folder, dirNoSharer)
mkdir(t, folder, dirGarbage)
// rhea sees only the dirs that name both sharer and rhea.
checkMagicListing(t, s, magicShare, []string{dirSharerRhea})
// joe is matched by no dir.
s.setPeer(remote1, peerJoe, sharerLogin)
checkMagicListing(t, s, magicShare, nil)
// fserb on another node sees their own dirs.
s.setPeer(remote1, sharerLogin, sharerLogin)
checkMagicListing(t, s, magicShare, []string{dirSharerOnly, dirSharerRhea})
// A peer not in any dir, but with the share grant, gets an empty
// listing (not 404).
s.setPeer(remote1, peerStranger, sharerLogin)
checkMagicListing(t, s, magicShare, nil)
}
func TestMagicShareReadWrite(t *testing.T) {
const (
magicShare = "magic"
sharerLogin = "fserb@example.com"
peerRhea = "rhea@example.com"
peerJoe = "joe@example.com"
dirShared = "fserb+rhea"
dirOther = "fserb"
dirInvalid = "rhea+joe"
fname = "note.txt"
)
s := newSystem(t)
s.addRemote(remote1)
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
mkdir(t, folder, dirShared)
mkdir(t, folder, dirOther)
mkdir(t, folder, dirInvalid)
// rhea can read/write inside dirShared.
rheaPath := path.Join(domain, remote1, magicShare, dirShared, fname)
if err := s.client.Write(rheaPath, []byte("hi from rhea"), 0644); err != nil {
t.Fatalf("rhea write into %q: %v", dirShared, err)
}
// Verify it actually landed on disk under the share folder.
onDisk := filepath.Join(folder, dirShared, fname)
if b, err := os.ReadFile(onDisk); err != nil {
t.Fatalf("on-disk read after write: %v (file %s)", err, onDisk)
} else if string(b) != "hi from rhea" {
t.Fatalf("on-disk content %q != written %q", b, "hi from rhea")
}
got, err := s.client.Read(rheaPath)
if err != nil {
t.Fatalf("rhea read from %q: %v", dirShared, err)
}
if string(got) != "hi from rhea" {
t.Errorf("rhea read got %q, want %q", got, "hi from rhea")
}
// rhea cannot write into a dir she's not in.
otherPath := path.Join(domain, remote1, magicShare, dirOther, fname)
if err := s.client.Write(otherPath, []byte("nope"), 0644); err == nil {
t.Errorf("rhea write into %q should have failed", dirOther)
}
// rhea cannot read from a dir she's not in.
if _, err := s.client.Read(otherPath); err == nil {
t.Errorf("rhea read from %q should have failed", dirOther)
}
// dirInvalid is invalid (no sharer in name): nobody, including the
// principals named, can access it.
s.setPeer(remote1, peerJoe, sharerLogin)
invalidPath := path.Join(domain, remote1, magicShare, dirInvalid, fname)
if err := s.client.Write(invalidPath, []byte("x"), 0644); err == nil {
t.Errorf("joe write into invalid dir %q should have failed", dirInvalid)
}
// joe also cannot list anything (no matching dirs).
checkMagicListing(t, s, magicShare, nil)
}
func TestMagicShareTopLevelMutationsDenied(t *testing.T) {
const (
magicShare = "magic"
sharerLogin = "fserb@example.com"
peerRhea = "rhea@example.com"
dirShared = "fserb+rhea"
)
s := newSystem(t)
s.addRemote(remote1)
folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin)
mkdir(t, folder, dirShared)
// MKCOL of a brand new top-level dir name must fail.
newDir := path.Join(domain, remote1, magicShare, "fserb+rhea+joe")
if err := s.client.Mkdir(newDir, 0755); err == nil {
t.Errorf("mkcol of new top-level dir should have been denied")
}
// DELETE of an existing top-level dir must fail.
existingDir := path.Join(domain, remote1, magicShare, dirShared)
if err := s.client.RemoveAll(existingDir); err == nil {
t.Errorf("delete of existing top-level dir should have been denied")
}
// But files inside the dir can be deleted normally.
filePath := path.Join(existingDir, "f.txt")
if err := s.client.Write(filePath, []byte("hi"), 0644); err != nil {
t.Fatalf("write inside acl dir: %v", err)
}
if err := s.client.Remove(filePath); err != nil {
t.Errorf("delete inside acl dir failed: %v", err)
}
}
// checkMagicListing verifies that a depth-1 listing of /<domain>/<remote>/<magicShare>/
// returns exactly want (in any order).
func checkMagicListing(t *testing.T, s *system, magicShare string, want []string) {
t.Helper()
entries, err := s.client.ReadDir(path.Join(domain, remote1, magicShare))
if err != nil {
t.Fatalf("readdir: %v", err)
}
got := make([]string, 0, len(entries))
for _, e := range entries {
got = append(got, e.Name())
}
sort.Strings(got)
sortedWant := append([]string(nil), want...)
sort.Strings(sortedWant)
if !slices.Equal(got, sortedWant) {
t.Errorf("listing got %v, want %v", got, sortedWant)
}
}

View File

@ -27,6 +27,7 @@ import (
"tailscale.com/drive/driveimpl/compositedav"
"tailscale.com/drive/driveimpl/dirfs"
"tailscale.com/drive/driveimpl/shared"
"tailscale.com/feature/buildfeatures"
"tailscale.com/safesocket"
"tailscale.com/types/logger"
)
@ -199,11 +200,14 @@ func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child
}
// ServeHTTPWithPerms implements drive.FileSystemForRemote.
func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions, w http.ResponseWriter, r *http.Request) {
func (s *FileSystemForRemote) ServeHTTPWithPerms(authz drive.Authz, w http.ResponseWriter, r *http.Request) {
permissions := authz.Permissions
pathComponents := shared.CleanAndSplit(r.URL.Path)
shareName := pathComponents[0]
isWrite := writeMethods[r.Method]
if isWrite {
share := shared.CleanAndSplit(r.URL.Path)[0]
switch permissions.For(share) {
switch permissions.For(shareName) {
case drive.PermissionNone:
// If we have no permissions to this share, treat it as not found
// to avoid leaking any information about the share's existence.
@ -216,16 +220,32 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions,
}
s.mu.RLock()
var matchedShare *drive.Share
for _, sh := range s.shares {
if sh.Name == shareName {
matchedShare = sh
break
}
}
childrenMap := s.children
s.mu.RUnlock()
if s.maybeServeMagic(authz, matchedShare, pathComponents, w, r) {
return
}
children := make([]*compositedav.Child, 0, len(childrenMap))
// filter out shares to which the connecting principal has no access
// filter out shares to which the connecting principal has no access. The
// magic share, if present, is also excluded here so a peer with the
// share-level grant can't bypass per-dir filtering by hitting top-level
// dirs directly through compositedav: it's served only via serveMagic.
for name, child := range childrenMap {
if permissions.For(name) == drive.PermissionNone {
continue
}
if buildfeatures.HasDriveMagic && name == drive.MagicShareName {
continue
}
children = append(children, child)
}

View File

@ -0,0 +1,147 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive_magic
package driveimpl
import (
"net/http"
"os"
"path/filepath"
"sort"
"github.com/tailscale/xnet/webdav"
"tailscale.com/drive"
"tailscale.com/drive/driveimpl/compositedav"
"tailscale.com/drive/driveimpl/dirfs"
"tailscale.com/drive/magic"
)
// topLevelDirWriteMethods are write-ish WebDAV methods that, when targeted
// at a magic share's top-level ACL directory itself (e.g. /<magic>/<acldir>),
// must be denied. Top-level dir creation/deletion is equivalent to writing
// a grant and is sharer-local only.
var topLevelDirWriteMethods = map[string]bool{
"MKCOL": true,
"DELETE": true,
"MOVE": true,
"PROPPATCH": true,
"COPY": true,
}
// maybeServeMagic dispatches to the magic-share handler if share is a magic
// share, returning true if the request was handled.
func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool {
if !share.IsMagic() {
return false
}
s.serveMagic(authz, share, pathComponents, w, r)
return true
}
// serveMagic handles a request whose first path segment is a magic share.
// It enforces ACL-name filtering: peers only see (and can only descend into)
// top-level directories whose name encodes an ACL they match. Top-level dir
// creation/deletion is denied for remote peers.
func (s *FileSystemForRemote) serveMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) {
if len(pathComponents) == 1 || (len(pathComponents) == 2 && pathComponents[1] == "") {
if writeMethods[r.Method] {
http.Error(w, "magic share top-level is read-only for remote peers", http.StatusForbidden)
return
}
s.serveMagicTopLevel(authz, share, w, r)
return
}
aclDir := pathComponents[1]
if !aclDirGrantsPeer(authz, share.Path, aclDir) {
// Use 404 rather than 403 to avoid confirming the dir's existence.
http.Error(w, "not found", http.StatusNotFound)
return
}
if len(pathComponents) == 2 && topLevelDirWriteMethods[r.Method] {
http.Error(w, "magic top-level dir is sharer-local only", http.StatusForbidden)
return
}
// Forward to the regular compositedav pipeline. We rebuild children
// rather than reusing the cached map directly, because the magic share
// must remain accessible here even though it was filtered out above.
s.mu.RLock()
childrenMap := s.children
s.mu.RUnlock()
children := make([]*compositedav.Child, 0, len(childrenMap))
for name, child := range childrenMap {
if name != share.Name && authz.Permissions.For(name) == drive.PermissionNone {
continue
}
children = append(children, child)
}
h := compositedav.Handler{Logf: s.logf}
h.SetChildren("", children...)
h.ServeHTTP(w, r)
}
// serveMagicTopLevel serves a synthetic listing of /<share>/ that contains
// only the top-level dirs the peer matches.
func (s *FileSystemForRemote) serveMagicTopLevel(authz drive.Authz, share *drive.Share, w http.ResponseWriter, r *http.Request) {
var children []*dirfs.Child
if authz.SharerLogin != "" {
entries, err := os.ReadDir(share.Path)
if err != nil {
s.logf("taildrive magic: read dir %q: %v", share.Path, err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
acl, err := magic.ParseDirACL(e.Name())
if err != nil {
continue
}
if acl.Matches(authz.PeerLogin, authz.SharerLogin) {
names = append(names, e.Name())
}
}
sort.Strings(names)
children = make([]*dirfs.Child, 0, len(names))
for _, n := range names {
children = append(children, &dirfs.Child{Name: n})
}
}
wh := &webdav.Handler{
LockSystem: webdav.NewMemLS(),
FileSystem: &dirfs.FS{
Children: children,
StaticRoot: share.Name,
},
}
wh.ServeHTTP(w, r)
}
// aclDirGrantsPeer reports whether aclDir exists on disk under sharePath as a
// directory and its parsed ACL grants the peer access (sharer-in-name rule
// plus peer match). It does no enumeration; only the single aclDir is
// inspected.
func aclDirGrantsPeer(authz drive.Authz, sharePath, aclDir string) bool {
if authz.SharerLogin == "" {
return false
}
acl, err := magic.ParseDirACL(aclDir)
if err != nil {
return false
}
if !acl.Matches(authz.PeerLogin, authz.SharerLogin) {
return false
}
fi, err := os.Stat(filepath.Join(sharePath, aclDir))
if err != nil || !fi.IsDir() {
return false
}
return true
}

View File

@ -0,0 +1,19 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_drive_magic
package driveimpl
import (
"net/http"
"tailscale.com/drive"
)
// maybeServeMagic is a stub for builds without the magic-share feature.
// It never claims the request, so a share named "magic" is served as a
// regular share with no name-encoded ACL semantics.
func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool {
return false
}

144
drive/magic/magic.go Normal file
View File

@ -0,0 +1,144 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package magic implements the parsing and matching logic for the
// taildrive "magic" share, where the directory name itself encodes the ACL.
//
// A magic ACL directory name is a "+"-delimited list of principals. Each
// principal is either a short login (matching the local-part of a tailnet
// LoginName) or a full login email. The sharer's own login must appear in
// every directory name; a directory whose name omits the sharer is
// considered invalid and shared with no one.
package magic
import (
"errors"
"fmt"
"strings"
)
// ErrInvalidName is returned by ParseDirACL when the directory name is empty
// or contains a malformed principal.
var ErrInvalidName = errors.New("invalid magic acl directory name")
// DirACL is a parsed magic-share directory name.
type DirACL struct {
// Users is the deduplicated list of user specs in the directory name,
// in the order they first appeared, normalized to lowercase.
// Each spec is either a short login (no "@") or a full login email.
Users []string
}
// ParseDirACL parses name as a magic ACL. Names are normalized to lowercase
// and split on "+". Returns ErrInvalidName if name is empty or contains a
// malformed principal.
func ParseDirACL(name string) (DirACL, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return DirACL{}, ErrInvalidName
}
parts := strings.Split(name, "+")
users := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, p := range parts {
if !validUserSpec(p) {
return DirACL{}, fmt.Errorf("%w: %q", ErrInvalidName, p)
}
if seen[p] {
continue
}
seen[p] = true
users = append(users, p)
}
return DirACL{Users: users}, nil
}
// validUserSpec reports whether s is a syntactically valid user spec.
// A short login is a non-empty sequence of [a-z0-9_.-]. An email is a short
// login, "@", and a non-empty sequence of [a-z0-9.-] (a hostname-ish
// fragment).
func validUserSpec(s string) bool {
if s == "" {
return false
}
local, host, hasAt := strings.Cut(s, "@")
if local == "" {
return false
}
if !validLocalPart(local) {
return false
}
if !hasAt {
return true
}
return validHost(host)
}
func validLocalPart(s string) bool {
for _, r := range s {
switch {
case 'a' <= r && r <= 'z':
case '0' <= r && r <= '9':
case r == '_' || r == '.' || r == '-':
default:
return false
}
}
return true
}
func validHost(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case 'a' <= r && r <= 'z':
case '0' <= r && r <= '9':
case r == '.' || r == '-':
default:
return false
}
}
return true
}
// Matches reports whether peerLogin is granted access by this ACL given that
// the local node's tailnet login is sharerLogin. Both arguments are full
// LoginNames (e.g. "alice@example.com") and are matched case-insensitively.
//
// The "sharer-in-name" rule is enforced: if no principal in the ACL matches
// sharerLogin, the directory is invalid and Matches returns false for every
// peer.
func (a DirACL) Matches(peerLogin, sharerLogin string) bool {
peerLogin = strings.ToLower(peerLogin)
sharerLogin = strings.ToLower(sharerLogin)
sharerOK := false
peerOK := false
for _, u := range a.Users {
if matchesUser(u, sharerLogin) {
sharerOK = true
}
if matchesUser(u, peerLogin) {
peerOK = true
}
}
return sharerOK && peerOK
}
// matchesUser reports whether spec matches login. spec and login are
// pre-lowercased. spec is either a short login (matched against the
// local-part of login) or a full email (matched against login as a whole).
func matchesUser(spec, login string) bool {
if login == "" {
return false
}
if strings.ContainsRune(spec, '@') {
return spec == login
}
local, _, ok := strings.Cut(login, "@")
if !ok {
local = login
}
return spec == local
}

82
drive/magic/magic_test.go Normal file
View File

@ -0,0 +1,82 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package magic
import (
"slices"
"testing"
)
func TestParseDirACL(t *testing.T) {
tests := []struct {
name string
want []string
wantErr bool
}{
{"fserb", []string{"fserb"}, false},
{"fserb+rhea", []string{"fserb", "rhea"}, false},
{"Fserb+RHEA", []string{"fserb", "rhea"}, false},
{"fserb+fserb", []string{"fserb"}, false},
{"fserb+rhea+joe", []string{"fserb", "rhea", "joe"}, false},
{"fserb@example.com+rhea", []string{"fserb@example.com", "rhea"}, false},
{"", nil, true},
{"+fserb", nil, true},
{"fserb+", nil, true},
{"fserb++rhea", nil, true},
{"fserb rhea", nil, true},
{"fserb!", nil, true},
{"@example.com", nil, true},
{"fserb@", nil, true},
{"fserb@@example.com", nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseDirACL(tt.name)
if (err != nil) != tt.wantErr {
t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !slices.Equal(got.Users, tt.want) {
t.Errorf("Users=%v, want %v", got.Users, tt.want)
}
})
}
}
func TestMatches(t *testing.T) {
const sharer = "fserb@example.com"
tests := []struct {
name string
dir string
peerLogin string
want bool
}{
{"sharer matches own dir", "fserb", sharer, true},
{"sharer matches paired dir", "fserb+rhea", sharer, true},
{"peer matches paired dir (short)", "fserb+rhea", "rhea@example.com", true},
{"peer matches paired dir (email)", "fserb+rhea@example.com", "rhea@example.com", true},
{"peer mismatch full email", "fserb+rhea@other.com", "rhea@example.com", false},
{"peer not in dir", "fserb+rhea", "joe@example.com", false},
{"sharer-not-in-name invalidates dir for peer", "rhea+joe", "rhea@example.com", false},
{"sharer-not-in-name invalidates dir for sharer too", "rhea+joe", sharer, false},
{"empty peer login", "fserb", "", false},
{"three principal dir, peer ok", "fserb+rhea+joe", "joe@example.com", true},
{"case-insensitive peer", "fserb+rhea", "RHEA@example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acl, err := ParseDirACL(tt.dir)
if err != nil {
t.Fatalf("parse %q: %v", tt.dir, err)
}
got := acl.Matches(tt.peerLogin, sharer)
if got != tt.want {
t.Errorf("Matches(%q, %q)=%v, want %v", tt.peerLogin, sharer, got, tt.want)
}
})
}
}

View File

@ -48,6 +48,17 @@ type Share struct {
BookmarkData []byte `json:"bookmarkData,omitempty"`
}
// MagicShareName is the reserved share name for a magic share, where each
// top-level directory name inside the share encodes its own ACL (see package
// drive/magic). A share is treated as magic if and only if its Name equals
// MagicShareName.
const MagicShareName = "magic"
// IsMagic reports whether s is a magic share.
func (s *Share) IsMagic() bool {
return s != nil && s.Name == MagicShareName
}
func ShareViewsEqual(a, b ShareView) bool {
if !a.Valid() && !b.Valid() {
return true
@ -102,9 +113,8 @@ type FileSystemForRemote interface {
SetShares(shares []*Share)
// ServeHTTPWithPerms behaves like the similar method from http.Handler but
// also accepts a Permissions map that captures the permissions of the
// connecting node.
ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request)
// also accepts an Authz value capturing the peer's identity and permissions.
ServeHTTPWithPerms(authz Authz, w http.ResponseWriter, r *http.Request)
// Close() stops serving the WebDAV content
Close() error

View File

@ -27,6 +27,23 @@ const (
// set of shares.
type Permissions map[string]Permission
// Authz captures the authorization context for a request to
// FileSystemForRemote: the peer's tailnet identity and per-share permissions,
// plus the local node's tailnet identity (used for the magic share's
// "sharer-in-name" rule).
type Authz struct {
// PeerLogin is the requesting peer's tailnet LoginName (e.g.
// "alice@example.com"). Lowercased for case-insensitive matching.
PeerLogin string
// SharerLogin is the local (sharing) node's tailnet LoginName.
// Lowercased for case-insensitive matching.
SharerLogin string
// Permissions maps share name to the peer's permission on that share.
Permissions Permissions
}
type grant struct {
Shares []string
Access string

View File

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_drive_magic
package buildfeatures
// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)".
// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag.
// It's a const so it can be used for dead code elimination.
const HasDriveMagic = false

View File

@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_drive_magic
package buildfeatures
// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)".
// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag.
// It's a const so it can be used for dead code elimination.
const HasDriveMagic = true

View File

@ -160,6 +160,11 @@ var Features = map[FeatureTag]FeatureMeta{
"desktop_sessions": {Sym: "DesktopSessions", Desc: "Desktop sessions support"},
"doctor": {Sym: "Doctor", Desc: "Diagnose possible issues with Tailscale and its host environment"},
"drive": {Sym: "Drive", Desc: "Tailscale Drive (file server) support"},
"drive_magic": {
Sym: "DriveMagic",
Desc: "Taildrive \"magic\" share (directory-name-encoded ACLs)",
Deps: []FeatureTag{"drive"},
},
"gro": {
Sym: "GRO",
Desc: "Generic Receive Offload support (performance)",

View File

@ -68,7 +68,7 @@ func TestRequiredBy(t *testing.T) {
}{
{
in: "drive",
want: setOf("drive"),
want: setOf("drive", "drive_magic"),
},
{
in: "webclient",

View File

@ -11,6 +11,7 @@ import (
"strings"
"tailscale.com/drive"
"tailscale.com/feature/buildfeatures"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
)
@ -53,6 +54,20 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
return
}
var sharerLogin string
if buildfeatures.HasDriveMagic {
if nm := h.ps.b.NetMap(); nm != nil {
if up, ok := nm.UserProfiles[nm.User()]; ok {
sharerLogin = up.LoginName()
}
}
}
authz := drive.Authz{
PeerLogin: strings.ToLower(h.peerUser.LoginName),
SharerLogin: strings.ToLower(sharerLogin),
Permissions: p,
}
fs, ok := h.ps.b.sys.DriveForRemote.GetOK()
if !ok {
h.logf("taildrive: not supported on platform")
@ -86,7 +101,7 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request)
}()
r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix)
fs.ServeHTTPWithPerms(p, wr, r)
fs.ServeHTTPWithPerms(authz, wr, r)
}
// parseDriveFileExtensionForLog parses the file extension, if available.