mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-29 17:58:15 +08:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user