ipn/ipn{auth,server}: update ipnauth.Actor to carry a context

The context carries additional information about the actor, such as the
request reason, and is canceled when the actor is done.

Additionally, we implement three new ipn.Actor types that wrap other actors
to modify their behavior:
 - WithRequestReason, which adds a request reason to the actor;
 - WithoutClose, which narrows the actor's interface to prevent it from being
   closed;
 - WithPolicyChecks, which adds policy checks to the actor's CheckProfileAccess
   method.

Updates #14823

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-02-07 10:47:14 -06:00 committed by Nick Khyl
parent 5a082fccec
commit e9e2bc5bd7
5 changed files with 77 additions and 6 deletions

View File

@ -4,9 +4,11 @@
package ipnauth
import (
"context"
"encoding/json"
"fmt"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
)
@ -32,6 +34,11 @@ type Actor interface {
// a connected LocalAPI client. Otherwise, it returns a zero value and false.
ClientID() (_ ClientID, ok bool)
// Context returns the context associated with the actor.
// It carries additional information about the actor
// and is canceled when the actor is done.
Context() context.Context
// CheckProfileAccess checks whether the actor has the necessary access rights
// to perform a given action on the specified Tailscale profile.
// It returns an error if access is denied.
@ -102,3 +109,27 @@ func (id ClientID) MarshalJSON() ([]byte, error) {
func (id *ClientID) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &id.v)
}
type actorWithRequestReason struct {
Actor
ctx context.Context
}
// WithRequestReason returns an [Actor] that wraps the given actor and
// carries the specified request reason in its context.
func WithRequestReason(actor Actor, requestReason string) Actor {
ctx := apitype.RequestReasonKey.WithValue(actor.Context(), requestReason)
return &actorWithRequestReason{Actor: actor, ctx: ctx}
}
// Context implements [Actor].
func (a *actorWithRequestReason) Context() context.Context { return a.ctx }
type withoutCloseActor struct{ Actor }
// WithoutClose returns an [Actor] that does not expose the [ActorCloser] interface.
// In other words, _, ok := WithoutClose(actor).(ActorCloser) will always be false,
// even if the original actor implements [ActorCloser].
func WithoutClose(actor Actor) Actor {
return withoutCloseActor{actor}
}

View File

@ -7,10 +7,36 @@ import (
"errors"
"fmt"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/util/syspolicy"
)
type actorWithPolicyChecks struct{ Actor }
// WithPolicyChecks returns an [Actor] that wraps the given actor and
// performs additional policy checks on top of the access checks
// implemented by the wrapped actor.
func WithPolicyChecks(actor Actor) Actor {
// TODO(nickkhyl): We should probably exclude the Windows Local System
// account from policy checks as well.
switch actor.(type) {
case unrestricted:
return actor
default:
return &actorWithPolicyChecks{Actor: actor}
}
}
// CheckProfileAccess implements [Actor].
func (a actorWithPolicyChecks) CheckProfileAccess(profile ipn.LoginProfileView, requestedAccess ProfileAccess, auditLogger AuditLogFunc) error {
if err := a.Actor.CheckProfileAccess(profile, requestedAccess, auditLogger); err != nil {
return err
}
requestReason := apitype.RequestReasonKey.Value(a.Context())
return CheckDisconnectPolicy(a.Actor, profile, requestReason, auditLogger)
}
// CheckDisconnectPolicy checks if the policy allows the specified actor to disconnect
// Tailscale with the given optional reason. It returns nil if the operation is allowed,
// or an error if it is not. If auditLogger is non-nil, it is called to log the action

View File

@ -4,6 +4,8 @@
package ipnauth
import (
"context"
"tailscale.com/ipn"
)
@ -17,18 +19,21 @@ var Self Actor = unrestricted{}
type unrestricted struct{}
// UserID implements [Actor].
func (u unrestricted) UserID() ipn.WindowsUserID { return "" }
func (unrestricted) UserID() ipn.WindowsUserID { return "" }
// Username implements [Actor].
func (u unrestricted) Username() (string, error) { return "", nil }
func (unrestricted) Username() (string, error) { return "", nil }
// Context implements [Actor].
func (unrestricted) Context() context.Context { return context.Background() }
// ClientID implements [Actor].
// It always returns (NoClientID, false) because the tailscaled itself
// is not a connected LocalAPI client.
func (u unrestricted) ClientID() (_ ClientID, ok bool) { return NoClientID, false }
func (unrestricted) ClientID() (_ ClientID, ok bool) { return NoClientID, false }
// CheckProfileAccess implements [Actor].
func (u unrestricted) CheckProfileAccess(_ ipn.LoginProfileView, _ ProfileAccess, _ AuditLogFunc) error {
func (unrestricted) CheckProfileAccess(_ ipn.LoginProfileView, _ ProfileAccess, _ AuditLogFunc) error {
// Unrestricted access to all profiles.
return nil
}
@ -37,10 +42,10 @@ func (u unrestricted) CheckProfileAccess(_ ipn.LoginProfileView, _ ProfileAccess
//
// Deprecated: this method exists for compatibility with the current (as of 2025-01-28)
// permission model and will be removed as we progress on tailscale/corp#18342.
func (u unrestricted) IsLocalSystem() bool { return false }
func (unrestricted) IsLocalSystem() bool { return false }
// IsLocalAdmin implements [Actor].
//
// Deprecated: this method exists for compatibility with the current (as of 2025-01-28)
// permission model and will be removed as we progress on tailscale/corp#18342.
func (u unrestricted) IsLocalAdmin(operatorUID string) bool { return false }
func (unrestricted) IsLocalAdmin(operatorUID string) bool { return false }

View File

@ -4,6 +4,8 @@
package ipnauth
import (
"cmp"
"context"
"errors"
"tailscale.com/ipn"
@ -17,6 +19,7 @@ type TestActor struct {
Name string // username associated with the actor, or ""
NameErr error // error to be returned by [TestActor.Username]
CID ClientID // non-zero if the actor represents a connected LocalAPI client
Ctx context.Context // context associated with the actor
LocalSystem bool // whether the actor represents the special Local System account on Windows
LocalAdmin bool // whether the actor has local admin access
}
@ -30,6 +33,9 @@ func (a *TestActor) Username() (string, error) { return a.Name, a.NameErr }
// ClientID implements [Actor].
func (a *TestActor) ClientID() (_ ClientID, ok bool) { return a.CID, a.CID != NoClientID }
// Context implements [Actor].
func (a *TestActor) Context() context.Context { return cmp.Or(a.Ctx, context.Background()) }
// CheckProfileAccess implements [Actor].
func (a *TestActor) CheckProfileAccess(profile ipn.LoginProfileView, _ ProfileAccess, _ AuditLogFunc) error {
return errors.New("profile access denied")

View File

@ -118,6 +118,9 @@ func (a *actor) ClientID() (_ ipnauth.ClientID, ok bool) {
return a.clientID, a.clientID != ipnauth.NoClientID
}
// Context implements [ipnauth.Actor].
func (a *actor) Context() context.Context { return context.Background() }
// Username implements [ipnauth.Actor].
func (a *actor) Username() (string, error) {
if a.ci == nil {