Stable UNIX users: functionality (#51200)

* wiring the API client through

* tctl stable-unix-users ls

* stable UID fallback for Teleport SSHD

* stable_unix_user.create event

* fix-license

* Require and test audit log message

* Add stable_unix_user_config to fileconf

* Avoid saying "fallback UID" in logs

* Avoid saying "fallback UID" in error messages

* Avoid racing writes in the mock emitter

* pnpm lint-fix

* fix lib/auth tests that broken during rebase
This commit is contained in:
Edoardo Spadolini
2025-01-29 18:15:57 +00:00
committed by GitHub
parent b61e70ea19
commit cc77fcebdf
25 changed files with 2411 additions and 1180 deletions
+6
View File
@@ -94,6 +94,7 @@ import (
resourceusagepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/resourceusage/v1"
samlidppb "github.com/gravitational/teleport/api/gen/proto/go/teleport/samlidp/v1"
secreportsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/secreports/v1"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1"
userloginstatev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/userloginstate/v1"
userprovisioningpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/userprovisioning/v2"
@@ -4970,6 +4971,11 @@ func (c *Client) GitServerReadOnlyClient() gitserverclient.ReadOnlyClient {
return c.GitServerClient()
}
// StableUNIXUsersClient returns a client for the stable UNIX users API.
func (c *Client) StableUNIXUsersClient() stableunixusersv1.StableUNIXUsersServiceClient {
return stableunixusersv1.NewStableUNIXUsersServiceClient(c.conn)
}
// GetCertAuthority retrieves a CA by type and domain.
func (c *Client) GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) {
ca, err := c.TrustClient().GetCertAuthority(ctx, &trustpb.GetCertAuthorityRequest{
@@ -4749,6 +4749,7 @@ message OneOf {
events.GitCommand GitCommand = 197;
events.UserLoginAccessListInvalid UserLoginAccessListInvalid = 198;
events.AccessRequestExpire AccessRequestExpire = 199;
events.StableUNIXUserCreate StableUNIXUserCreate = 200;
}
}
@@ -7950,3 +7951,28 @@ message UserLoginAccessListInvalid {
(gogoproto.jsontag) = ""
];
}
// StableUNIXUserCreate is emitted whenever a new stable UNIX user is written in
// the cluster state storage.
message StableUNIXUserCreate {
// Metadata is common event metadata
Metadata Metadata = 1 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
// User is a common user event metadata
UserMetadata User = 2 [
(gogoproto.nullable) = false,
(gogoproto.embed) = true,
(gogoproto.jsontag) = ""
];
StableUNIXUser stable_unix_user = 3;
}
message StableUNIXUser {
string username = 1;
int32 uid = 2;
}
+5
View File
@@ -2434,3 +2434,8 @@ func (m *WorkloadIdentityDelete) TrimToMaxSize(_ int) AuditEvent {
func (m *GitCommand) TrimToMaxSize(_ int) AuditEvent {
return m
}
// TrimToMaxSize implements [AuditEvent].
func (m *StableUNIXUserCreate) TrimToMaxSize(int) AuditEvent {
return m
}
+1693 -1123
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -823,6 +823,10 @@ func ToOneOf(in AuditEvent) (*OneOf, error) {
out.Event = &OneOf_GitCommand{
GitCommand: e,
}
case *StableUNIXUserCreate:
out.Event = &OneOf_StableUNIXUserCreate{
StableUNIXUserCreate: e,
}
default:
slog.ErrorContext(context.Background(), "Attempted to convert dynamic event of unknown type into protobuf event.", "event_type", in.GetType())
unknown := &Unknown{}
+4
View File
@@ -53,6 +53,7 @@ import (
provisioningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/provisioning/v1"
resourceusagepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/resourceusage/v1"
samlidppb "github.com/gravitational/teleport/api/gen/proto/go/teleport/samlidp/v1"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1"
userspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1"
"github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1"
@@ -1879,6 +1880,9 @@ type ClientI interface {
// will return "not implemented" errors (as per the default gRPC behavior).
StaticHostUserClient() services.StaticHostUser
// StableUNIXUsersClient returns a client for the stable UNIX users API.
StableUNIXUsersClient() stableunixusersv1.StableUNIXUsersServiceClient
// CloneHTTPClient creates a new HTTP client with the same configuration.
CloneHTTPClient(params ...roundtrip.ClientParam) (*HTTPClient, error)
+2
View File
@@ -5312,6 +5312,8 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
stableUNIXUsersServiceServer, err := stableunixusers.New(stableunixusers.Config{
Authorizer: cfg.Authorizer,
Emitter: cfg.Emitter,
Logger: cfg.AuthServer.logger.With(teleport.ComponentKey, "stable_unix_users"),
Backend: cfg.AuthServer.bk,
ReadOnlyCache: cfg.AuthServer.ReadOnlyCache,
+60 -12
View File
@@ -19,6 +19,7 @@ package stableunixusers
import (
"context"
"errors"
"log/slog"
"time"
"github.com/gravitational/trace"
@@ -26,8 +27,10 @@ import (
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/readonly"
"github.com/gravitational/teleport/lib/utils"
@@ -38,6 +41,8 @@ const uidCacheTTL = 30 * time.Second
// Config contains the parameters for [New].
type Config struct {
Authorizer authz.Authorizer
Emitter apievents.Emitter
Logger *slog.Logger
Backend backend.Backend
ReadOnlyCache *readonly.Cache
@@ -54,11 +59,33 @@ type Config struct {
// New returns the auth server implementation for the stable UNIX users service,
// including the gRPC interface, authz enforcement, and business logic.
func New(params Config) (stableunixusersv1.StableUNIXUsersServiceServer, error) {
func New(c Config) (stableunixusersv1.StableUNIXUsersServiceServer, error) {
if c.Authorizer == nil {
return nil, trace.BadParameter("missing Authorizer")
}
if c.Emitter == nil {
return nil, trace.BadParameter("missing Emitter")
}
if c.Logger == nil {
return nil, trace.BadParameter("missing Logger")
}
if c.Backend == nil {
return nil, trace.BadParameter("missing Backend")
}
if c.ReadOnlyCache == nil {
return nil, trace.BadParameter("missing ReadOnlyCache")
}
if c.StableUNIXUsers == nil {
return nil, trace.BadParameter("missing StableUNIXUsers")
}
if c.ClusterConfiguration == nil {
return nil, trace.BadParameter("missing ClusterConfiguration")
}
uidCache, err := utils.NewFnCache(utils.FnCacheConfig{
TTL: uidCacheTTL,
Clock: params.CacheClock,
Context: params.CacheContext,
Clock: c.CacheClock,
Context: c.CacheContext,
ReloadOnErr: true,
})
if err != nil {
@@ -66,13 +93,15 @@ func New(params Config) (stableunixusersv1.StableUNIXUsersServiceServer, error)
}
return &server{
authorizer: params.Authorizer,
authorizer: c.Authorizer,
emitter: c.Emitter,
logger: c.Logger,
backend: params.Backend,
readOnlyCache: params.ReadOnlyCache,
backend: c.Backend,
readOnlyCache: c.ReadOnlyCache,
stableUNIXUsers: params.StableUNIXUsers,
clusterConfiguration: params.ClusterConfiguration,
stableUNIXUsers: c.StableUNIXUsers,
clusterConfiguration: c.ClusterConfiguration,
uidCache: uidCache,
@@ -86,6 +115,8 @@ type server struct {
stableunixusersv1.UnsafeStableUNIXUsersServiceServer
authorizer authz.Authorizer
emitter apievents.Emitter
logger *slog.Logger
backend backend.Backend
readOnlyCache *readonly.Cache
@@ -161,9 +192,17 @@ func (s *server) ObtainUIDForUsername(ctx context.Context, req *stableunixusersv
// obtainUIDForUsernameCached calls [*server.obtainUIDForUsernameUncached]
// through the UID FnCache.
func (s *server) obtainUIDForUsernameCached(ctx context.Context, username string) (int32, error) {
idGetter, err := authz.UserFromContext(ctx)
if err != nil {
idGetter = nil
}
uid, err := utils.FnCacheGet(ctx, s.uidCache, username, func(ctx context.Context) (int32, error) {
ctx, cancel := context.WithTimeout(ctx, uidCacheTTL)
defer cancel()
if idGetter != nil {
ctx = authz.ContextWithUser(ctx, idGetter)
}
return s.obtainUIDForUsernameUncached(ctx, username)
})
if err != nil {
@@ -192,8 +231,6 @@ func (s *server) obtainUIDForUsernameUncached(ctx context.Context, username stri
uid, err := s.stableUNIXUsers.GetUIDForUsername(ctx, username)
if err == nil {
// TODO(espadolini): _potentially_ emit an audit log event with
// username and UID (it might spam the audit log unnecessarily)
return uid, nil
}
if !trace.IsNotFound(err) {
@@ -233,8 +270,19 @@ func (s *server) obtainUIDForUsernameUncached(ctx context.Context, username stri
return 0, trace.Wrap(err)
}
// TODO(espadolini): emit an audit log event with the username and UID
// that was just created
if err := s.emitter.EmitAuditEvent(ctx, &apievents.StableUNIXUserCreate{
Metadata: apievents.Metadata{
Type: events.StableUNIXUserCreateEvent,
Code: events.StableUNIXUserCreateCode,
},
UserMetadata: authz.ClientUserMetadata(ctx),
StableUnixUser: &apievents.StableUNIXUser{
Username: username,
Uid: uid,
},
}); err != nil {
s.logger.WarnContext(ctx, "Failed to emit stable_unix_user.create", "error", err)
}
return uid, nil
}
@@ -19,6 +19,8 @@ package stableunixusers_test
import (
"context"
"fmt"
"log/slog"
"os"
"testing"
"time"
@@ -29,13 +31,20 @@ import (
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/auth/stableunixusers"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/backend/memory"
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/services/readonly"
"github.com/gravitational/teleport/lib/utils"
)
func TestMain(m *testing.M) {
utils.InitLoggerForTests()
os.Exit(m.Run())
}
func TestStableUNIXUsers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -58,11 +67,15 @@ func TestStableUNIXUsers(t *testing.T) {
Backend: bk,
}
emitter := new(mockEmitter)
var authorizer authz.AuthorizerFunc
cacheClock := clockwork.NewFakeClock()
svc, err := stableunixusers.New(stableunixusers.Config{
Authorizer: &authorizer,
Emitter: emitter,
Logger: slog.Default(),
Backend: bk,
ReadOnlyCache: readOnlyCache,
@@ -114,38 +127,46 @@ func TestStableUNIXUsers(t *testing.T) {
uid1, err := obtainUIDForUsername("user1")
require.NoError(t, err)
require.Equal(t, firstUID, uid1)
require.True(t, emitter.getAndReset())
// this will panic unless the internal time-based cache is working
stableUNIXUsers.Backend = nil
uid1, err = obtainUIDForUsername("user1")
require.NoError(t, err)
require.Equal(t, firstUID, uid1)
require.False(t, emitter.getAndReset())
stableUNIXUsers.Backend = bk
uid2, err := obtainUIDForUsernameUncached("user2")
require.NoError(t, err)
require.Equal(t, firstUID+1, uid2)
require.True(t, emitter.getAndReset())
uid1, err = obtainUIDForUsernameUncached("user1")
require.NoError(t, err)
require.Equal(t, firstUID, uid1)
require.False(t, emitter.getAndReset())
uid2, err = obtainUIDForUsernameUncached("user2")
require.NoError(t, err)
require.Equal(t, firstUID+1, uid2)
require.False(t, emitter.getAndReset())
uid3, err := obtainUIDForUsernameUncached("user3")
require.NoError(t, err)
require.Equal(t, firstUID+2, uid3)
require.True(t, emitter.getAndReset())
uid4, err := obtainUIDForUsernameUncached("user4")
require.NoError(t, err)
require.Equal(t, firstUID+3, uid4)
require.True(t, emitter.getAndReset())
// 90000-90003 is only four spots, we can't store the fifth user
_, err = obtainUIDForUsernameUncached("user5")
require.ErrorAs(t, err, new(*trace.LimitExceededError))
require.False(t, emitter.getAndReset())
// nodes are not allowed to list users
_, err = svc.ListStableUNIXUsers(ctx, &stableunixusersv1.ListStableUNIXUsersRequest{
@@ -193,6 +214,7 @@ func TestStableUNIXUsers(t *testing.T) {
_, err = clusterConfiguration.UpsertAuthPreference(ctx, authPref)
require.NoError(t, err)
emitter.emitted = true
eg, ctx := errgroup.WithContext(ctx)
for i := range 1000 {
eg.Go(func() error {
@@ -202,3 +224,21 @@ func TestStableUNIXUsers(t *testing.T) {
}
require.NoError(t, eg.Wait())
}
type mockEmitter struct {
emitted bool
}
func (e *mockEmitter) EmitAuditEvent(ctx context.Context, ev apievents.AuditEvent) error {
if !e.emitted {
// avoid racing a write if the flag is already set
e.emitted = true
}
return nil
}
func (e *mockEmitter) getAndReset() bool {
r := e.emitted
e.emitted = false
return r
}
+46 -1
View File
@@ -1050,6 +1050,9 @@ type AuthenticationConfig struct {
// SignatureAlgorithmSuite is the configured signature algorithm suite for the cluster.
SignatureAlgorithmSuite types.SignatureAlgorithmSuite `yaml:"signature_algorithm_suite"`
// StableUNIXUserConfig is [types.AuthPreferenceSpecV2.StableUnixUserConfig].
StableUNIXUserConfig *StableUNIXUserConfig `yaml:"stable_unix_user_config,omitempty"`
}
// Parse returns valid types.AuthPreference instance.
@@ -1107,7 +1110,12 @@ func (a *AuthenticationConfig) Parse() (types.AuthPreference, error) {
slog.WarnContext(context.Background(), msg)
}
return types.NewAuthPreferenceFromConfigFile(types.AuthPreferenceSpecV2{
stableUNIXUserConfig, err := a.StableUNIXUserConfig.Parse()
if err != nil {
return nil, trace.Wrap(err, "failed to parse stable_unix_user_config")
}
ap, err := types.NewAuthPreferenceFromConfigFile(types.AuthPreferenceSpecV2{
Type: a.Type,
SecondFactor: a.SecondFactor,
SecondFactors: a.SecondFactors,
@@ -1123,7 +1131,17 @@ func (a *AuthenticationConfig) Parse() (types.AuthPreference, error) {
DefaultSessionTTL: a.DefaultSessionTTL,
HardwareKey: h,
SignatureAlgorithmSuite: a.SignatureAlgorithmSuite,
StableUnixUserConfig: stableUNIXUserConfig,
})
if err != nil {
return nil, trace.Wrap(err)
}
if err := services.ValidateAuthPreference(ap); err != nil {
return nil, trace.Wrap(err)
}
return ap, nil
}
type UniversalSecondFactor struct {
@@ -1307,6 +1325,33 @@ func (h *HardwareKeySerialNumberValidation) Parse() (*types.HardwareKeySerialNum
}, nil
}
// StableUNIXUserConfig is [types.StableUNIXUserConfig].
type StableUNIXUserConfig struct {
// Enabled is [types.StableUNIXUserConfig.Enabled].
Enabled bool `yaml:"enabled"`
// FirstUID is [types.StableUNIXUserConfig.FirstUid].
FirstUID int32 `yaml:"first_uid"`
// LastUID is [types.StableUNIXUserConfig.LastUid].
LastUID int32 `yaml:"last_uid"`
}
func (s *StableUNIXUserConfig) Parse() (*types.StableUNIXUserConfig, error) {
if s == nil {
return nil, nil
}
c := &types.StableUNIXUserConfig{
Enabled: s.Enabled,
FirstUid: s.FirstUID,
LastUid: s.LastUID,
}
if err := services.ValidateStableUNIXUserConfig(c); err != nil {
return nil, trace.Wrap(err)
}
return c, nil
}
// HostedPlugins defines 'auth_service/plugins' Enterprise extension
type HostedPlugins struct {
Enabled bool `yaml:"enabled"`
+42
View File
@@ -460,6 +460,24 @@ func TestAuthenticationSection(t *testing.T) {
expectError: func(t require.TestingT, err error, msgAndArgs ...interface{}) {
require.ErrorContains(t, err, "invalid value: balanced-v0")
},
}, {
desc: "stable unix users",
mutate: func(cfg cfgMap) {
cfg["auth_service"].(cfgMap)["authentication"] = cfgMap{
"stable_unix_user_config": cfgMap{
"enabled": true,
"first_uid": 100,
"last_uid": 10,
},
}
},
expected: &AuthenticationConfig{
StableUNIXUserConfig: &StableUNIXUserConfig{
Enabled: true,
FirstUID: 100,
LastUID: 10,
},
},
},
}
for _, tt := range tests {
@@ -836,6 +854,30 @@ func TestAuthenticationConfig_Parse_deviceTrustPB(t *testing.T) {
}
}
func TestAuthenticationConfig_Parse_StableUNIXUserConfig(t *testing.T) {
text := editConfig(t, func(cfg cfgMap) {
cfg["auth_service"].(cfgMap)["authentication"] = cfgMap{
"stable_unix_user_config": nil,
}
})
cfg, err := ReadConfig(bytes.NewBuffer(text))
require.NoError(t, err)
_, err = cfg.Auth.Authentication.Parse()
require.NoError(t, err)
text = editConfig(t, func(cfg cfgMap) {
cfg["auth_service"].(cfgMap)["authentication"] = cfgMap{
"stable_unix_user_config": cfgMap{
"enabled": true,
},
}
})
cfg, err = ReadConfig(bytes.NewBuffer(text))
require.NoError(t, err)
_, err = cfg.Auth.Authentication.Parse()
require.ErrorContains(t, err, "UID range includes negative or system UIDs")
}
// TestSSHSection tests the config parser for the SSH config block
func TestSSHSection(t *testing.T) {
t.Parallel()
+3
View File
@@ -861,6 +861,9 @@ const (
// GitCommandEvent is emitted when a Git command is executed.
GitCommandEvent = "git.command"
// StableUNIXUserCreateEvent is emitted when a stable UNIX user is created.
StableUNIXUserCreateEvent = "stable_unix_user.create"
)
// Add an entry to eventsMap in lib/events/events_test.go when you add
+3
View File
@@ -693,6 +693,9 @@ const (
// GitCommandFailureCode is the git command feature event code.
GitCommandFailureCode = "TGIT001E"
// StableUNIXUserCreateCode is the stable UNIX user create event code.
StableUNIXUserCreateCode = "TSUU001I"
// UnknownCode is used when an event of unknown type is encountered.
UnknownCode = apievents.UnknownCode
)
+3
View File
@@ -487,6 +487,9 @@ func FromEventFields(fields EventFields) (events.AuditEvent, error) {
case WorkloadIdentityDeleteEvent:
e = &events.WorkloadIdentityDelete{}
case StableUNIXUserCreateEvent:
e = &events.StableUNIXUserCreate{}
default:
slog.ErrorContext(context.Background(), "Attempted to convert dynamic event of unknown type into protobuf event.", "event_type", eventType)
unknown := &events.Unknown{}
+1
View File
@@ -249,6 +249,7 @@ var eventsMap = map[string]apievents.AuditEvent{
WorkloadIdentityUpdateEvent: &apievents.WorkloadIdentityUpdate{},
WorkloadIdentityDeleteEvent: &apievents.WorkloadIdentityDelete{},
AccessRequestExpireEvent: &apievents.AccessRequestExpire{},
StableUNIXUserCreateEvent: &apievents.StableUNIXUserCreate{},
}
// TestJSON tests JSON marshal events
+1
View File
@@ -3125,6 +3125,7 @@ func (process *TeleportProcess) initSSH() error {
regular.SetTracerProvider(process.TracingProvider),
regular.SetSessionController(sessionController),
regular.SetPublicAddrs(cfg.SSH.PublicAddrs),
regular.SetStableUNIXUsers(conn.Client.StableUNIXUsersClient()),
)
if err != nil {
return trace.Wrap(err)
+48 -1
View File
@@ -46,6 +46,7 @@ import (
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
"github.com/gravitational/teleport/api/observability/tracing"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/api/types"
@@ -235,6 +236,10 @@ type Server struct {
// remoteForwardingMap holds the remote port forwarding listeners that need
// to be closed when forwarding finishes, keyed by listen addr.
remoteForwardingMap utils.SyncMap[string, io.Closer]
// stableUnixUsers is used to obtain fallback UIDs for host user
// provisioning from the control plane.
stableUnixUsers stableunixusersv1.StableUNIXUsersServiceClient
}
// TargetMetadata returns metadata about the server.
@@ -698,6 +703,15 @@ func SetPROXYSigner(proxySigner PROXYHeaderSigner) ServerOption {
}
}
// SetStableUNIXUsers sets the client for the stable UNIX users service, used as
// a fallback to get UIDs for host user creation.
func SetStableUNIXUsers(stableUNIXUsers stableunixusersv1.StableUNIXUsersServiceClient) ServerOption {
return func(s *Server) error {
s.stableUnixUsers = stableUNIXUsers
return nil
}
}
// SetPublicAddrs sets the server's public addresses
func SetPublicAddrs(addrs []utils.NetAddr) ServerOption {
return func(s *Server) error {
@@ -1258,7 +1272,7 @@ func (s *Server) HandleNewConn(ctx context.Context, ccx *sshutils.ConnectionCont
}
// Create host user.
created, userCloser, err := s.termHandlers.SessionRegistry.UpsertHostUser(identityContext)
created, userCloser, err := s.termHandlers.SessionRegistry.UpsertHostUser(identityContext, s.obtainFallbackUID)
if err != nil {
s.logger.WarnContext(ctx, "error while creating host users", "error", err)
}
@@ -1281,6 +1295,39 @@ func (s *Server) HandleNewConn(ctx context.Context, ccx *sshutils.ConnectionCont
return ctx, nil
}
// obtainFallbackUID checks if the cluster is configured for stable
// autoprovisioned UNIX user UIDs and, if so, obtains and returns the UID for
// the given username. If the cluster is not configured for stable UIDs, it
// returns (_, false, nil).
func (s *Server) obtainFallbackUID(ctx context.Context, username string) (uid int32, ok bool, _ error) {
authPref, err := s.authService.GetAuthPreference(ctx)
if err != nil {
return 0, false, trace.Wrap(err)
}
cfg := authPref.GetStableUNIXUserConfig()
if cfg == nil || !cfg.Enabled {
return 0, false, nil
}
resp, err := s.stableUnixUsers.ObtainUIDForUsername(ctx, &stableunixusersv1.ObtainUIDForUsernameRequest{
Username: username,
})
if err != nil {
return 0, false, trace.Wrap(err)
}
uid = resp.GetUid()
// see https://github.com/systemd/systemd/blob/cc7300fc5868f6d47f3f47076100b574bf54e58d/docs/UIDS-GIDS.md
const firstUserUID = 1000
if uid < firstUserUID {
return 0, false, trace.BadParameter("received a negative or system UID as the new UID from the control plane (%v)", uid)
}
return uid, true, nil
}
// HandleNewChan is called when new channel is opened
func (s *Server) HandleNewChan(ctx context.Context, ccx *sshutils.ConnectionContext, nch ssh.NewChannel) {
identityContext, err := s.authHandlers.CreateIdentityContext(ccx.ServerConn)
+114 -2
View File
@@ -46,12 +46,14 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"google.golang.org/grpc"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/breaker"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
@@ -853,7 +855,6 @@ func TestDirectTCPIP(t *testing.T) {
// Startup a test server that will reply with "hello, world\n"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello, world")
}))
defer ts.Close()
@@ -3318,11 +3319,122 @@ func TestHostUserCreationProxy(t *testing.T) {
_, _, err = reg.UpsertHostUser(srv.IdentityContext{
AccessChecker: &fakeAccessChecker{},
})
}, srv.ObtainFallbackUIDFunc(nil))
assert.NoError(t, err)
assert.Empty(t, usersBackend.calls, 0)
}
func TestObtainFallbackUID(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
type testCase struct {
config *types.StableUNIXUserConfig
obtainFunc func(username string) (int32, error)
username string
required func(uid int32, ok bool, err error)
}
for _, tc := range []testCase{{
config: nil,
obtainFunc: func(username string) (int32, error) {
require.FailNow(t, "called obtainFunc")
panic("unreachable")
},
username: "",
required: func(uid int32, ok bool, err error) {
require.NoError(t, err)
require.False(t, ok)
},
}, {
config: &types.StableUNIXUserConfig{
Enabled: false,
},
obtainFunc: func(username string) (int32, error) {
require.FailNow(t, "called obtainFunc")
panic("unreachable")
},
username: "",
required: func(uid int32, ok bool, err error) {
require.NoError(t, err)
require.False(t, ok)
},
}, {
config: &types.StableUNIXUserConfig{
Enabled: true,
},
obtainFunc: func(username string) (int32, error) {
require.Equal(t, "foo", username)
return 325872, nil
},
username: "foo",
required: func(uid int32, ok bool, err error) {
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, int32(325872), uid)
},
}, {
config: &types.StableUNIXUserConfig{
Enabled: true,
},
obtainFunc: func(username string) (int32, error) {
require.Equal(t, "bar", username)
return 0, trace.LimitExceeded("no UIDs or something")
},
username: "bar",
required: func(uid int32, ok bool, err error) {
require.ErrorIs(t, err, &trace.LimitExceededError{Message: "no UIDs or something"})
},
}, {
config: &types.StableUNIXUserConfig{
Enabled: true,
},
obtainFunc: func(username string) (int32, error) {
require.Equal(t, "baz", username)
return 0, trace.BadParameter("some bug, idk")
},
username: "baz",
required: func(uid int32, ok bool, err error) {
require.ErrorIs(t, err, &trace.BadParameterError{Message: "some bug, idk"})
},
}} {
srv := &Server{
authService: getAuthPreferenceAccessPoint{
authPreference: &types.AuthPreferenceV2{Spec: types.AuthPreferenceSpecV2{
StableUnixUserConfig: tc.config,
}},
},
stableUnixUsers: obtainUIDForUsernameFunc(tc.obtainFunc),
}
tc.required(srv.obtainFallbackUID(ctx, tc.username))
}
}
type getAuthPreferenceAccessPoint struct {
srv.AccessPoint
authPreference types.AuthPreference
}
func (a getAuthPreferenceAccessPoint) GetAuthPreference(ctx context.Context) (types.AuthPreference, error) {
return a.authPreference, nil
}
type obtainUIDForUsernameFunc func(username string) (int32, error)
func (f obtainUIDForUsernameFunc) ObtainUIDForUsername(ctx context.Context, in *stableunixusersv1.ObtainUIDForUsernameRequest, opts ...grpc.CallOption) (*stableunixusersv1.ObtainUIDForUsernameResponse, error) {
uid, err := f(in.GetUsername())
if err != nil {
return nil, trace.Wrap(err)
}
return &stableunixusersv1.ObtainUIDForUsernameResponse{Uid: uid}, nil
}
func (f obtainUIDForUsernameFunc) ListStableUNIXUsers(ctx context.Context, in *stableunixusersv1.ListStableUNIXUsersRequest, opts ...grpc.CallOption) (*stableunixusersv1.ListStableUNIXUsersResponse, error) {
return nil, trace.NotImplemented("ListStableUNIXUsers")
}
type fakeAccessChecker struct {
services.AccessChecker
}
+41 -13
View File
@@ -30,6 +30,7 @@ import (
"os/user"
"path/filepath"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"
@@ -284,10 +285,14 @@ func (s *SessionRegistry) WriteSudoersFile(identityContext IdentityContext) (io.
}, nil
}
// ObtainFallbackUIDFunc should return (uid, true, nil) if a fallback UID is
// configured, (_, false, nil) if no fallback is configured.
type ObtainFallbackUIDFunc = func(ctx context.Context, username string) (uid int32, ok bool, _ error)
// UpsertHostUser attempts to create or update a local user on the host if needed.
// If the returned closer is not nil, it must be called at the end of the session to
// clean up the local user.
func (s *SessionRegistry) UpsertHostUser(identityContext IdentityContext) (bool, io.Closer, error) {
func (s *SessionRegistry) UpsertHostUser(identityContext IdentityContext, obtainFallbackUID ObtainFallbackUIDFunc) (bool, io.Closer, error) {
ctx := s.Srv.Context()
log := s.logger.With("host_username", identityContext.Login)
@@ -300,23 +305,46 @@ func (s *SessionRegistry) UpsertHostUser(identityContext IdentityContext) (bool,
return false, nil, nil // not an error to not be able to create host users
}
log.DebugContext(ctx, "Attempting to upsert host user")
ui, accessErr := identityContext.AccessChecker.HostUsers(s.Srv.GetInfo())
if trace.IsAccessDenied(accessErr) {
existsErr := s.users.UserExists(identityContext.Login)
if existsErr != nil {
if trace.IsNotFound(existsErr) {
return false, nil, trace.WrapWithMessage(accessErr, "insufficient permissions for host user creation")
log.DebugContext(ctx, "Checking if user provisioning is allowed")
ui, err := identityContext.AccessChecker.HostUsers(s.Srv.GetInfo())
if err != nil {
if trace.IsAccessDenied(err) {
if existsErr := s.users.UserExists(identityContext.Login); existsErr != nil {
if trace.IsNotFound(existsErr) {
return false, nil, trace.WrapWithMessage(err, "insufficient permissions for host user creation")
}
return false, nil, trace.Wrap(existsErr)
}
}
return false, nil, trace.Wrap(err)
}
if obtainFallbackUID != nil && ui.Mode == services.HostUserModeKeep && ui.UID == "" {
if err := s.users.UserExists(identityContext.Login); err != nil {
if !trace.IsNotFound(err) {
return false, nil, trace.Wrap(err)
}
return false, nil, trace.Wrap(existsErr)
log.DebugContext(ctx, "Host user does not exist and no UID is configured, obtaining UID from control plane")
fallbackUID, ok, err := obtainFallbackUID(ctx, identityContext.Login)
if err != nil {
log.ErrorContext(ctx, "Failed to obtain UID from control plane", "error", err)
return false, nil, trace.Wrap(err)
}
if ok {
log.DebugContext(ctx, "Obtained UID from control plane", "uid", fallbackUID)
ui.UID = strconv.Itoa(int(fallbackUID))
if ui.GID == "" {
ui.GID = ui.UID
}
} else {
log.DebugContext(ctx, "No UID configured in the cluster")
}
}
}
if accessErr != nil {
return false, nil, trace.Wrap(accessErr)
}
log.DebugContext(ctx, "Attempting to upsert host user")
userCloser, err := s.users.UpsertUser(identityContext.Login, *ui)
if err != nil {
log.DebugContext(ctx, "Error creating user", "error", err)
+124 -28
View File
@@ -1430,7 +1430,6 @@ func mockSSHSession(t *testing.T) *tracessh.Session {
require.Fail(t, "timeout while waiting for the SSH session")
return nil
}
}
func TestUpsertHostUser(t *testing.T) {
@@ -1439,14 +1438,15 @@ func TestUpsertHostUser(t *testing.T) {
cases := []struct {
name string
identityContext IdentityContext
hostUsers *fakeHostUsersBackend
createHostUser bool
identityContext IdentityContext
hostUsers *fakeHostUsersBackend
createHostUser bool
obtainFallbackUID ObtainFallbackUIDFunc
expectCreated bool
expectErrIs error
expectErrContains string
expectUsers map[string][]string
expectUsers map[string]fakeUser
}{
{
name: "should upsert existing user with permission",
@@ -1459,14 +1459,14 @@ func TestUpsertHostUser(t *testing.T) {
},
},
},
hostUsers: &fakeHostUsersBackend{users: map[string][]string{
hostUsers: &fakeHostUsersBackend{users: map[string]fakeUser{
username: {},
}},
expectCreated: true,
expectUsers: map[string][]string{
username: {"foo", "bar"},
expectUsers: map[string]fakeUser{
username: {groups: []string{"foo", "bar"}},
},
},
{
@@ -1483,8 +1483,8 @@ func TestUpsertHostUser(t *testing.T) {
hostUsers: &fakeHostUsersBackend{},
expectCreated: true,
expectUsers: map[string][]string{
username: {"foo", "bar"},
expectUsers: map[string]fakeUser{
username: {groups: []string{"foo", "bar"}},
},
},
{
@@ -1492,14 +1492,14 @@ func TestUpsertHostUser(t *testing.T) {
createHostUser: true,
identityContext: IdentityContext{Login: username, AccessChecker: &fakeAccessChecker{err: trace.AccessDenied("test")}},
hostUsers: &fakeHostUsersBackend{
users: map[string][]string{
users: map[string]fakeUser{
username: {},
},
},
expectCreated: false,
expectErrIs: trace.AccessDenied("test"),
expectUsers: map[string][]string{
expectUsers: map[string]fakeUser{
username: {},
},
},
@@ -1510,7 +1510,7 @@ func TestUpsertHostUser(t *testing.T) {
hostUsers: &fakeHostUsersBackend{},
expectCreated: false,
expectUsers: make(map[string][]string),
expectUsers: nil,
expectErrIs: trace.AccessDenied("test"),
expectErrContains: "insufficient permissions for host user creation",
},
@@ -1521,7 +1521,96 @@ func TestUpsertHostUser(t *testing.T) {
hostUsers: &fakeHostUsersBackend{},
expectCreated: false,
expectUsers: make(map[string][]string),
expectUsers: nil,
},
{
name: "should use fallback UIDs in keep mode",
createHostUser: true,
identityContext: IdentityContext{
Login: username,
AccessChecker: &fakeAccessChecker{
hostInfo: services.HostUsersInfo{
Mode: services.HostUserModeKeep,
},
},
},
hostUsers: &fakeHostUsersBackend{},
obtainFallbackUID: func(ctx context.Context, username string) (uid int32, ok bool, _ error) {
return 1, true, nil
},
expectCreated: true,
expectUsers: map[string]fakeUser{
username: {uid: "1", gid: "1"},
},
},
{
name: "should only use fallback UIDs in keep mode",
createHostUser: true,
identityContext: IdentityContext{
Login: username,
AccessChecker: &fakeAccessChecker{
hostInfo: services.HostUsersInfo{
Mode: services.HostUserModeDrop,
},
},
},
hostUsers: &fakeHostUsersBackend{},
obtainFallbackUID: func(ctx context.Context, username string) (uid int32, ok bool, _ error) {
return 0, false, trace.BadParameter("not reached")
},
expectCreated: true,
expectUsers: map[string]fakeUser{
username: {},
},
},
{
name: "should only use fallback UIDs for users that don't exist",
createHostUser: true,
identityContext: IdentityContext{
Login: username,
AccessChecker: &fakeAccessChecker{
hostInfo: services.HostUsersInfo{
Mode: services.HostUserModeKeep,
},
},
},
hostUsers: &fakeHostUsersBackend{
users: map[string]fakeUser{
username: {},
},
},
obtainFallbackUID: func(ctx context.Context, username string) (uid int32, ok bool, _ error) {
return 0, false, trace.BadParameter("not reached")
},
expectCreated: true,
expectUsers: map[string]fakeUser{
username: {},
},
},
{
name: "should not override a configured GID",
createHostUser: true,
identityContext: IdentityContext{
Login: username,
AccessChecker: &fakeAccessChecker{
hostInfo: services.HostUsersInfo{
Mode: services.HostUserModeKeep,
GID: "set",
},
},
},
hostUsers: &fakeHostUsersBackend{},
obtainFallbackUID: func(ctx context.Context, username string) (uid int32, ok bool, _ error) {
return 1, true, nil
},
expectCreated: true,
expectUsers: map[string]fakeUser{
username: {uid: "1", gid: "set"},
},
},
}
@@ -1535,7 +1624,7 @@ func TestUpsertHostUser(t *testing.T) {
users: c.hostUsers,
}
userCreated, _, err := registry.UpsertHostUser(c.identityContext)
userCreated, _, err := registry.UpsertHostUser(c.identityContext, c.obtainFallbackUID)
if c.expectErrIs != nil {
assert.ErrorIs(t, err, c.expectErrIs)
@@ -1551,10 +1640,12 @@ func TestUpsertHostUser(t *testing.T) {
assert.Equal(t, c.expectCreated, userCreated)
for name, groups := range c.hostUsers.users {
expectedGroups, ok := c.expectUsers[name]
for name, user := range c.hostUsers.users {
expectedUser, ok := c.expectUsers[name]
assert.True(t, ok, "user must be present in expected users")
assert.ElementsMatch(t, expectedGroups, groups)
assert.ElementsMatch(t, expectedUser.groups, user.groups)
assert.Equal(t, expectedUser.uid, user.uid)
assert.Equal(t, expectedUser.gid, user.gid)
}
})
}
@@ -1671,29 +1762,34 @@ func (f *fakeAccessChecker) HostUsers(srv types.Server) (*services.HostUsersInfo
return &f.hostInfo, f.err
}
type fakeUser struct {
groups []string
uid string
gid string
}
type fakeHostUsersBackend struct {
HostUsers
users map[string][]string
users map[string]fakeUser
}
func (f *fakeHostUsersBackend) UpsertUser(name string, hostRoleInfo services.HostUsersInfo) (io.Closer, error) {
if f.users == nil {
f.users = make(map[string][]string)
f.users = make(map[string]fakeUser)
}
f.users[name] = hostRoleInfo.Groups
f.users[name] = fakeUser{
groups: hostRoleInfo.Groups,
uid: hostRoleInfo.UID,
gid: hostRoleInfo.GID,
}
return nil, nil
}
func (f *fakeHostUsersBackend) UserExists(name string) error {
if f.users == nil {
return trace.NotFound(name)
}
_, exists := f.users[name]
if !exists {
return trace.NotFound(name)
if _, exists := f.users[name]; !exists {
return trace.NotFound("%v", name)
}
return nil
+2
View File
@@ -22,6 +22,7 @@ import (
"github.com/gravitational/teleport/tool/tctl/common/accessmonitoring"
"github.com/gravitational/teleport/tool/tctl/common/loginrule"
"github.com/gravitational/teleport/tool/tctl/common/plugin"
"github.com/gravitational/teleport/tool/tctl/common/stableunixusers"
"github.com/gravitational/teleport/tool/tctl/common/top"
"github.com/gravitational/teleport/tool/tctl/sso/configure"
"github.com/gravitational/teleport/tool/tctl/sso/tester"
@@ -68,5 +69,6 @@ func Commands() []CLICommand {
&touchIDCommand{},
&TerraformCommand{},
&AutoUpdateCommand{},
&stableunixusers.Command{},
}
}
+125
View File
@@ -0,0 +1,125 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package stableunixusers
import (
"context"
"encoding/json"
"os"
"strconv"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1"
"github.com/gravitational/teleport/lib/asciitable"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/tool/tctl/common/client"
"github.com/gravitational/teleport/tool/tctl/common/config"
)
// Command implements the commands under "tctl stable-unix-users".
type Command struct {
lsCmd *kingpin.CmdClause
lsFormat string
}
// Initialize implements [tool/tctl/common.CLICommand].
func (c *Command) Initialize(app *kingpin.Application, _ *config.GlobalCLIFlags, _ *servicecfg.Config) {
rootCmd := app.Command("stable-unix-users", "Manage the database of stable UNIX users.")
c.lsCmd = rootCmd.Command("ls", "List the stable UNIX users currently persisted in the cluster.")
c.lsCmd.Flag("format", "Output format, 'text', or 'json'").Default(teleport.Text).EnumVar(&c.lsFormat, teleport.Text, teleport.JSON, teleport.YAML)
}
// TryRun implements [tool/tctl/common.CLICommand].
func (c *Command) TryRun(ctx context.Context, fullCommand string, clientFunc client.InitFunc) (match bool, err error) {
switch fullCommand {
default:
return false, nil
case c.lsCmd.FullCommand():
return true, trace.Wrap(c.ls(ctx, clientFunc))
}
}
// ls is the implementation of "tctl stable-unix-users ls".
func (c *Command) ls(ctx context.Context, clientFunc client.InitFunc) error {
authClient, close, err := clientFunc(ctx)
if err != nil {
return trace.Wrap(err)
}
defer close(ctx)
clt := authClient.StableUNIXUsersClient()
type userOut struct {
Username string `json:"username"`
UID int32 `json:"uid"`
}
var usersOut []userOut
var pageToken string
for {
resp, err := clt.ListStableUNIXUsers(ctx, &stableunixusersv1.ListStableUNIXUsersRequest{
PageSize: 0,
PageToken: pageToken,
})
if err != nil {
return trace.Wrap(err)
}
for _, user := range resp.GetStableUnixUsers() {
usersOut = append(usersOut, userOut{
Username: user.GetUsername(),
UID: user.GetUid(),
})
}
pageToken = resp.GetNextPageToken()
if pageToken == "" {
break
}
}
switch c.lsFormat {
default:
// format is an enum parameter, kingpin will check and error out if the
// value is unknown long before getting to this point
return trace.BadParameter("unknown format %q (this is a bug)", c.lsFormat)
case teleport.Text:
table := asciitable.MakeTable([]string{"Username", "UID"})
for _, u := range usersOut {
table.AddRow([]string{u.Username, strconv.Itoa(int(u.UID))})
}
if err := table.WriteTo(os.Stdout); err != nil {
return trace.Wrap(err)
}
case teleport.JSON, teleport.YAML:
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if usersOut == nil {
// avoid encoding a nil slice as "null"
usersOut = []userOut{}
}
if err := enc.Encode(usersOut); err != nil {
return trace.Wrap(err)
}
}
return nil
}
@@ -293,6 +293,7 @@ const EventIconMap: Record<EventCode, any> = {
[eventCodes.UNKNOWN]: Icons.Question,
[eventCodes.GIT_COMMAND]: Icons.GitHub,
[eventCodes.GIT_COMMAND_FAILURE]: Icons.GitHub,
[eventCodes.STABLE_UNIX_USER_CREATE]: Icons.Info,
};
export default function renderTypeCell(event: Event) {
@@ -2022,6 +2022,13 @@ export const formatters: Formatters = {
return `User [${user}] Git Command [${service}] at [${path}] failed [${exitError}]`;
},
},
[eventCodes.STABLE_UNIX_USER_CREATE]: {
type: 'stable_unix_user.create',
desc: 'Stable UNIX user created',
format: ({ stable_unix_user: { username } }) => {
return `Stable UNIX user for username [${username}] was created`;
},
},
};
const unknownFormatter = {
@@ -312,6 +312,7 @@ export const eventCodes = {
CONTACT_DELETE: 'TCTC002I',
GIT_COMMAND: 'TGIT001I',
GIT_COMMAND_FAILURE: 'TGIT001E',
STABLE_UNIX_USER_CREATE: 'TSUU001I',
} as const;
/**
@@ -1785,6 +1786,15 @@ export type RawEvents = {
exitError: string;
}
>;
[eventCodes.STABLE_UNIX_USER_CREATE]: RawEvent<
typeof eventCodes.STABLE_UNIX_USER_CREATE,
{
stable_unix_user: {
username: string;
uid: number;
};
}
>;
};
/**