Skip to content

Commit

Permalink
stable UNIX users storage
Browse files Browse the repository at this point in the history
  • Loading branch information
espadolini committed Jan 16, 2025
1 parent 6de2438 commit 1dc2767
Show file tree
Hide file tree
Showing 5 changed files with 399 additions and 0 deletions.
2 changes: 2 additions & 0 deletions buf-go.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ plugins:
# needed by teleport/lib/teleterm/v1/usage_events.proto because we use
# managed mode for the go package name there
- Mprehog/v1alpha/connect.proto=github.com/gravitational/teleport/gen/proto/go/prehog/v1alpha;prehogv1alpha
# buf (1.49.0 and earlier) panics on lint when encountering the option in the file itself (https://github.com/bufbuild/buf/issues/3580)
- apilevelMteleport/storage/local/stableunixusers/v1/stableunixusers.proto=API_OPAQUE
strategy: all
- local:
- go
Expand Down
195 changes: 195 additions & 0 deletions lib/services/local/stableunixusers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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 local

import (
"context"
"encoding/binary"
"encoding/hex"
"math"

"github.com/gravitational/trace"
"google.golang.org/protobuf/proto"

"github.com/gravitational/teleport/api/defaults"
stableunixusersv1 "github.com/gravitational/teleport/gen/proto/go/teleport/storage/local/stableunixusers/v1"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/services"
)

const (
stableUNIXUsersPrefix = "stable_unix_users"
stableUNIXUsersByUIDInfix = "by_uid"
stableUNIXUsersByUsernameInfix = "by_username"
)

// StableUNIXUsersService is the storage service implementation to interact with
// stable UNIX users.
type StableUNIXUsersService struct {
Backend backend.Backend
}

var _ services.StableUNIXUsersInternal = (*StableUNIXUsersService)(nil)

// GetUIDForUsername implements [services.StableUNIXUsersInternal].
func (s *StableUNIXUsersService) GetUIDForUsername(ctx context.Context, username string) (int32, error) {
if username == "" {
return 0, trace.BadParameter("username cannot be empty")
}

item, err := s.Backend.Get(ctx, s.usernameToKey(username))
if err != nil {
return 0, trace.Wrap(err)
}

m := new(stableunixusersv1.StableUNIXUser)
if err := proto.Unmarshal(item.Value, m); err != nil {
return 0, trace.Wrap(err)
}

return m.GetUid(), nil
}

// ListStableUNIXUsers implements [services.StableUNIXUsersInternal].
func (s *StableUNIXUsersService) ListStableUNIXUsers(ctx context.Context, pageSize int, pageToken string) (_ []services.StableUNIXUser, nextPageToken string, _ error) {
start := backend.ExactKey(stableUNIXUsersPrefix, stableUNIXUsersByUsernameInfix)
end := backend.RangeEnd(start)
if pageToken != "" {
start = s.usernameToKey(pageToken)
}

if pageSize <= 0 {
pageSize = defaults.DefaultChunkSize
}

items, err := s.Backend.GetRange(ctx, start, end, pageSize+1)
if err != nil {
return nil, "", trace.Wrap(err)
}

users := make([]services.StableUNIXUser, 0, len(items.Items))
for _, item := range items.Items {
userpb := new(stableunixusersv1.StableUNIXUser)
if err := proto.Unmarshal(item.Value, userpb); err != nil {
return nil, "", trace.Wrap(err)
}

users = append(users, services.StableUNIXUser{
Username: userpb.GetUsername(),
UID: userpb.GetUid(),
})
}

if len(users) > pageSize {
nextPageToken := users[pageSize].Username
clear(users[pageSize:])
return users[:pageSize], nextPageToken, nil
}

return users, "", nil
}

// SearchFreeUID implements [services.StableUNIXUsersInternal].
func (s *StableUNIXUsersService) SearchFreeUID(ctx context.Context, first int32, last int32) (int32, error) {
// uidToKey is monotonic decreasing, so by fetching the key range from last
// to first we will encounter UIDs in decreasing order
start := s.uidToKey(last)
end := s.uidToKey(first)

// TODO(espadolini): this logic is a big simplification that will only
// actually return a value in the empty range adjacent to "last", ignoring
// any free spots before the biggest UID in the range; as an improvement we
// could occasionally fall back to a full scan and keep track of the last
// free spot we've encountered in the full scan, which could then be used
// for later searches (this is something that could be implemented by the
// caller of SearchFreeUID)

r, err := s.Backend.GetRange(ctx, start, end, 1)
if err != nil {
return 0, trace.Wrap(err)
}

if len(r.Items) < 1 {
return first, nil
}

m := new(stableunixusersv1.StableUNIXUser)
if err := proto.Unmarshal(r.Items[0].Value, m); err != nil {
return 0, trace.Wrap(err)
}

uid := m.GetUid()
if uid < first || uid > last {
return 0, trace.Errorf("free UID search returned out of range value (this is a bug)")
}

if uid == last {
return 0, trace.LimitExceeded("out of available UIDs")
}

return uid + 1, nil
}

// AppendCreateUsernameUID implements [services.StableUNIXUsersInternal].
func (s *StableUNIXUsersService) AppendCreateStableUNIXUser(actions []backend.ConditionalAction, username string, uid int32) ([]backend.ConditionalAction, error) {
b, err := proto.Marshal(stableunixusersv1.StableUNIXUser_builder{
Username: proto.String(username),
Uid: proto.Int32(uid),
}.Build())
if err != nil {
return nil, trace.Wrap(err)
}

return append(actions,
backend.ConditionalAction{
Key: s.usernameToKey(username),
Condition: backend.NotExists(),
Action: backend.Put(backend.Item{
Value: b,
}),
},
backend.ConditionalAction{
Key: s.uidToKey(uid),
Condition: backend.NotExists(),
Action: backend.Put(backend.Item{
Value: b,
}),
},
), nil
}

// usernameToKey returns the key for the "by_username" item with the given
// username. To avoid confusion or encoding problems, the username is
// hex-encoded in the key.
func (*StableUNIXUsersService) usernameToKey(username string) backend.Key {
suffix := hex.EncodeToString([]byte(username))
return backend.NewKey(stableUNIXUsersPrefix, stableUNIXUsersByUsernameInfix, suffix)
}

// uidToKey returns the key for the "by_uid" item with the given UID. The
// resulting keys have the opposite order to the numbers, such that the key for
// [math.MaxInt32] is the smallest and the key for [math.MinInt32] is the
// largest.
func (*StableUNIXUsersService) uidToKey(uid int32) backend.Key {
// in two's complement (which Go specifies), this transformation maps
// +0x7fff_ffff (MaxInt32) to 0x0000_0000 and -0x8000_0000 (MinInt32) to
// 0xffff_ffff; as such, GetRange will return items from the largest to the
// smallest, which we rely on to get the largest allocated item in the range
// we're interested in
suffix := hex.EncodeToString(binary.BigEndian.AppendUint32(nil, math.MaxInt32-uint32(uid)))
return backend.NewKey(stableUNIXUsersPrefix, stableUNIXUsersByUIDInfix, suffix)
}
121 changes: 121 additions & 0 deletions lib/services/local/stableunixusers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 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 local

import (
"context"
"fmt"
"math/rand/v2"
"slices"
"testing"

"github.com/gravitational/trace"
"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/backend/memory"
)

func TestStableUNIXUsersBasic(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

bk, err := memory.New(memory.Config{
Component: "TestStableUNIXUsersBasic",
})
require.NoError(t, err)
defer bk.Close()

svc := &StableUNIXUsersService{
Backend: bk,
}

_, err = svc.GetUIDForUsername(ctx, "notfound")
require.ErrorAs(t, err, new(*trace.NotFoundError))

const baseUID int32 = 2000

acts, err := svc.AppendCreateStableUNIXUser(nil, "found", baseUID)
require.NoError(t, err)
_, err = bk.AtomicWrite(ctx, acts)
require.NoError(t, err)

acts, err = svc.AppendCreateStableUNIXUser(nil, "found", baseUID)
require.NoError(t, err)
_, err = bk.AtomicWrite(ctx, acts)
require.ErrorIs(t, err, backend.ErrConditionFailed)

uid, err := svc.GetUIDForUsername(ctx, "found")
require.NoError(t, err)
require.Equal(t, baseUID, uid)

uid, err = svc.SearchFreeUID(ctx, baseUID, baseUID+100)
require.NoError(t, err)
require.Equal(t, baseUID+1, uid)

for i := range 2 * defaults.DefaultChunkSize {
acts, err := svc.AppendCreateStableUNIXUser(nil, fmt.Sprintf("user%05d", i), baseUID+1+int32(i))
require.NoError(t, err)
_, err = bk.AtomicWrite(ctx, acts)
require.NoError(t, err)
}

_, err = svc.SearchFreeUID(ctx, baseUID, baseUID+defaults.DefaultChunkSize)
require.ErrorAs(t, err, new(*trace.LimitExceededError))

uid, err = svc.SearchFreeUID(ctx, baseUID, baseUID+3*defaults.DefaultChunkSize)
require.NoError(t, err)
require.Equal(t, baseUID+1+2*defaults.DefaultChunkSize, uid)

// TODO(espadolini): remove or adjust this if SearchFreeUID ends up
// searching more than the final part of the range
uid, err = svc.SearchFreeUID(ctx, baseUID-100, baseUID+3*defaults.DefaultChunkSize)
require.NoError(t, err)
require.Equal(t, baseUID+1+2*defaults.DefaultChunkSize, uid)

resp, nextPageToken, err := svc.ListStableUNIXUsers(ctx, 0, "")
require.NoError(t, err)
// "found" followed by "user00000", "user00001", ..., "user00999"
require.Equal(t, fmt.Sprintf("user%05d", defaults.DefaultChunkSize-1), nextPageToken)
require.Len(t, resp, defaults.DefaultChunkSize)

resp, _, err = svc.ListStableUNIXUsers(ctx, 0, nextPageToken)
require.NoError(t, err)
require.Len(t, resp, defaults.DefaultChunkSize)
require.Equal(t, fmt.Sprintf("user%05d", defaults.DefaultChunkSize-1), resp[0].Username)
require.Equal(t, baseUID+defaults.DefaultChunkSize, resp[0].UID)
}

func TestStableUNIXUsersUIDOrder(t *testing.T) {
t.Parallel()

nums := make([]int32, 0, 1000)
for range 1000 {
nums = append(nums, int32(rand.Uint32()))
}
slices.Sort(nums)

keys := make([]string, 0, len(nums))
for _, n := range slices.Backward(nums) {
keys = append(keys, (*StableUNIXUsersService).uidToKey(nil, n).String())
}

require.True(t, slices.IsSorted(keys), "uidToKey didn't satisfy key order")
}
53 changes: 53 additions & 0 deletions lib/services/stableunixusers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 services

import (
"context"

"github.com/gravitational/teleport/lib/backend"
)

// StableUNIXUsersInternal is the (auth-only) storage service to interact with
// stable UNIX users.
type StableUNIXUsersInternal interface {
// ListStableUNIXUsers returns a page of username/UID pairs. The returned
// next page token is empty when fetching the last page in the list;
// otherwise it can be passed to ListStableUNIXUsers to fetch the next page.
ListStableUNIXUsers(ctx context.Context, pageSize int, pageToken string) (_ []StableUNIXUser, nextPageToken string, _ error)

// GetUIDForUsername returns the stable UID associated with the given
// username, if one exists, or a NotFound.
GetUIDForUsername(context.Context, string) (int32, error)
// SearchFreeUID returns the first available UID in the range between first
// and last (included). If no stored UIDs are within the range, it returns
// first. If no UIDs are available in the range, it returns a LimitExceeded
// error.
SearchFreeUID(ctx context.Context, first, last int32) (int32, error)

// AppendCreateStableUNIXUser appends some atomic write actions to the given
// slice that will create a username/UID pair for a stable UNIX user. The
// backend to which the actions are applied should be the same backend used
// by the StableUNIXUsersInternal.
AppendCreateStableUNIXUser(actions []backend.ConditionalAction, username string, uid int32) ([]backend.ConditionalAction, error)
}

// StableUNIXUser is a username/UID pair representing a stable UNIX user.
type StableUNIXUser struct {
Username string
UID int32
}
Loading

0 comments on commit 1dc2767

Please sign in to comment.