diff --git a/ipn/ipnauth/actor.go b/ipn/ipnauth/actor.go index 446cb4635..8a0e77645 100644 --- a/ipn/ipnauth/actor.go +++ b/ipn/ipnauth/actor.go @@ -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} +} diff --git a/ipn/ipnauth/policy.go b/ipn/ipnauth/policy.go index c61f9cd89..f09be0fcb 100644 --- a/ipn/ipnauth/policy.go +++ b/ipn/ipnauth/policy.go @@ -7,10 +7,36 @@ "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 diff --git a/ipn/ipnauth/self.go b/ipn/ipnauth/self.go index 271be9815..9b430dc6d 100644 --- a/ipn/ipnauth/self.go +++ b/ipn/ipnauth/self.go @@ -4,6 +4,8 @@ package ipnauth import ( + "context" + "tailscale.com/ipn" ) @@ -17,18 +19,21 @@ 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 } diff --git a/ipn/ipnauth/test_actor.go b/ipn/ipnauth/test_actor.go index ba4e03c93..80c5fcc8a 100644 --- a/ipn/ipnauth/test_actor.go +++ b/ipn/ipnauth/test_actor.go @@ -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") diff --git a/ipn/ipnserver/actor.go b/ipn/ipnserver/actor.go index 6ee7a04d7..594ebf2d5 100644 --- a/ipn/ipnserver/actor.go +++ b/ipn/ipnserver/actor.go @@ -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 {