Files
teleport/lib/web/command_test.go
T
Jakub NyckowskiandBrian Joerger 7b5aec2043 Fix authorization rules to the Assistant and UserPreferences service (#29481)
* "Add authorization rules to the Assistant and UserPreferences service"

This commit introduces authorization rules into the Assistant service to restrict operations based on the authenticated user's role permissions.

Now each method in the Assistant service checks if the authenticated user has necessary permissions to perform the requested operation. The permissions are checked via defined RBAC rules.

A user requires specific permissions to perform various operations such as creating a conversation, updating a conversation, fetching a user's conversations, deleting a conversation, and adding a message to a conversation.

Also, even if a user has necessary permissions, they cannot perform operations for a different user. Each user can only access their own data.

* Add missing logger

* "Refactor user preferences request handling"

This commit refactors how GetUserPreferences and UpsertUserPreferences handle requests.
The `username` field is removed from request parameters. Instead of having the client send the user's username in a request, the server now automatically uses the username of the authenticated user making the request.
This change improves the security by preventing a user from attempting to fetch or manipulate another user's preferences. Removed tests were specifically testing the old, insecure behavior.

* Refactor to use authz.HasBuiltinRole

Refactored code in the 'auth_with_roles.go' file to use 'authz.HasBuiltinRole' instead of 'HasBuiltinRole'. This change is in line with recommended practices for deprecation and makes the code more standard and easier to manage. The original 'HasBuiltinRole' function is marked as deprecated and will be removed in future once 'teleport.e' is updated to use 'authz.HasBuiltinRole'.

* Reserve removed username again?

* Fix UT

* Add local user permissions checks in authz

This commit introduces two new methods in permissions.go to check if a user is a local user, and if a given action is performed by a local user. These permission checks are then used to replace existing checks in service.go, when performing actions like creating conversation, updating, listing, etc. This simplifies checks and provides a more consolidated and unified method for verifying user actions.

* Fix tests

* Tweak RBAC

* Address review comments

* Separate client and server interfaces for user preference services.

* Apply core review suggestions

* Apply suggestions from code review

Co-authored-by: Brian Joerger <bjoerger@goteleport.com>

---------

Co-authored-by: joerger <bjoerger@goteleport.com>
2023-08-02 20:55:01 +00:00

416 lines
12 KiB
Go

/*
* Copyright 2023 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package web
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gogo/protobuf/proto"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/gravitational/roundtrip"
"github.com/gravitational/trace"
"github.com/sashabaranov/go-openai"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/gravitational/teleport/api/gen/proto/go/assist/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/ai/testutils"
assistlib "github.com/gravitational/teleport/lib/assist"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/utils"
)
const (
testCommand = "echo txlxport | sed 's/x/e/g'"
testUser = "foo"
)
func TestExecuteCommand(t *testing.T) {
t.Parallel()
openAIMock := mockOpenAISummary(t)
openAIConfig := openai.DefaultConfig("test-token")
openAIConfig.BaseURL = openAIMock.URL
s := newWebSuiteWithConfig(t, webSuiteConfig{
disableDiskBasedRecording: true,
OpenAIConfig: &openAIConfig,
})
assistRole, err := types.NewRole("assist-access", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindAssistant, services.RW()),
},
},
})
require.NoError(t, err)
require.NoError(t, s.server.Auth().UpsertRole(s.ctx, assistRole))
ws, _, err := s.makeCommand(t, s.authPack(t, testUser, assistRole.GetName()), uuid.New())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, ws.Close()) })
stream := NewWStream(context.Background(), ws, utils.NewLoggerForTests(), nil)
require.NoError(t, waitForCommandOutput(stream, "teleport"))
}
func TestExecuteCommandHistory(t *testing.T) {
t.Parallel()
openAIMock := mockOpenAISummary(t)
openAIConfig := openai.DefaultConfig("test-token")
openAIConfig.BaseURL = openAIMock.URL
s := newWebSuiteWithConfig(t, webSuiteConfig{
disableDiskBasedRecording: true,
OpenAIConfig: &openAIConfig,
})
assistRole, err := types.NewRole("assist-access", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindAssistant, services.RW()),
},
},
})
require.NoError(t, err)
require.NoError(t, s.server.Auth().UpsertRole(s.ctx, assistRole))
authPack := s.authPack(t, testUser, assistRole.GetName())
ctx := context.Background()
clt, err := s.server.NewClient(auth.TestUser(testUser))
require.NoError(t, err)
// Create conversation, otherwise the command execution will not be saved
conversation, err := clt.CreateAssistantConversation(context.Background(), &assist.CreateAssistantConversationRequest{
Username: testUser,
CreatedTime: timestamppb.Now(),
})
require.NoError(t, err)
require.NotEmpty(t, conversation.GetId())
conversationID, err := uuid.Parse(conversation.GetId())
require.NoError(t, err)
ws, _, err := s.makeCommand(t, authPack, conversationID)
require.NoError(t, err)
stream := NewWStream(ctx, ws, utils.NewLoggerForTests(), nil)
// When command executes
require.NoError(t, waitForCommandOutput(stream, "teleport"))
// Close the stream if not already closed
_ = stream.Close()
// Then command execution history is saved
var messages *assist.GetAssistantMessagesResponse
// Command execution history is saved in asynchronously, so we need to wait for it.
require.Eventually(t, func() bool {
messages, err = clt.GetAssistantMessages(ctx, &assist.GetAssistantMessagesRequest{
ConversationId: conversationID.String(),
Username: testUser,
})
require.NoError(t, err)
return len(messagesByType(messages.GetMessages())[assistlib.MessageKindCommandResult]) == 1
}, 5*time.Second, 100*time.Millisecond)
// Assert the returned message
resultMessages, ok := messagesByType(messages.GetMessages())[assistlib.MessageKindCommandResult]
require.True(t, ok, "Message must be of type COMMAND_RESULT")
msg := resultMessages[0]
require.NotZero(t, msg.CreatedTime)
var result commandExecResult
err = json.Unmarshal([]byte(msg.GetPayload()), &result)
require.NoError(t, err)
require.NotEmpty(t, result.ExecutionID)
require.NotEmpty(t, result.SessionID)
require.Equal(t, "node", result.NodeName)
require.Equal(t, "node", result.NodeID)
}
func TestExecuteCommandSummary(t *testing.T) {
t.Parallel()
openAIMock := mockOpenAISummary(t)
openAIConfig := openai.DefaultConfig("test-token")
openAIConfig.BaseURL = openAIMock.URL
s := newWebSuiteWithConfig(t, webSuiteConfig{
disableDiskBasedRecording: true,
OpenAIConfig: &openAIConfig,
})
assistRole, err := types.NewRole("assist-access", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
types.NewRule(types.KindAssistant, services.RW()),
},
},
})
require.NoError(t, err)
require.NoError(t, s.server.Auth().UpsertRole(s.ctx, assistRole))
authPack := s.authPack(t, testUser, assistRole.GetName())
ctx := context.Background()
clt, err := s.server.NewClient(auth.TestUser(testUser))
require.NoError(t, err)
// Create conversation, otherwise the command execution will not be saved
conversation, err := clt.CreateAssistantConversation(context.Background(), &assist.CreateAssistantConversationRequest{
Username: testUser,
CreatedTime: timestamppb.Now(),
})
require.NoError(t, err)
require.NotEmpty(t, conversation.GetId())
conversationID, err := uuid.Parse(conversation.GetId())
require.NoError(t, err)
ws, _, err := s.makeCommand(t, authPack, conversationID)
require.NoError(t, err)
// For simplicity, use simple WS to io.Reader adapter
stream := &wsReader{conn: ws}
// Wait for command execution to complete
require.NoError(t, waitForCommandOutput(stream, "teleport"))
dec := json.NewDecoder(stream)
// Consume the close message
var sessionMetadata sessionEndEvent
err = dec.Decode(&sessionMetadata)
require.NoError(t, err)
require.Equal(t, "node", sessionMetadata.NodeID)
// Consume the summary message
var env outEnvelope
err = dec.Decode(&env)
require.NoError(t, err)
require.Equal(t, envelopeTypeSummary, env.Type)
require.NotEmpty(t, env.Payload)
// Wait for the command execution history to be saved
var messages *assist.GetAssistantMessagesResponse
// Command execution history is saved in asynchronously, so we need to wait for it.
require.Eventually(t, func() bool {
messages, err = clt.GetAssistantMessages(ctx, &assist.GetAssistantMessagesRequest{
ConversationId: conversationID.String(),
Username: testUser,
})
assert.NoError(t, err)
return len(messagesByType(messages.GetMessages())[assistlib.MessageKindCommandResultSummary]) == 1
}, 5*time.Second, 100*time.Millisecond)
// Check the returned summary message
summaryMessages, ok := messagesByType(messages.GetMessages())[assistlib.MessageKindCommandResultSummary]
require.True(t, ok, "At least one summary message is expected")
msg := summaryMessages[0]
require.NotZero(t, msg.CreatedTime)
var result assistlib.CommandExecSummary
err = json.Unmarshal([]byte(msg.GetPayload()), &result)
require.NoError(t, err)
require.NotEmpty(t, result.ExecutionID)
require.Equal(t, testCommand, result.Command)
require.NotEmpty(t, result.Summary)
}
func (s *WebSuite) makeCommand(t *testing.T, pack *authPack, conversationID uuid.UUID) (*websocket.Conn, *session.Session, error) {
req := CommandRequest{
Query: fmt.Sprintf("name == \"%s\"", s.srvID),
Login: pack.login,
ConversationID: conversationID.String(),
ExecutionID: uuid.New().String(),
Command: testCommand,
}
u := url.URL{
Host: s.url().Host,
Scheme: client.WSS,
Path: fmt.Sprintf("/v1/webapi/command/%v/execute", currentSiteShortcut),
}
data, err := json.Marshal(req)
if err != nil {
return nil, nil, err
}
q := u.Query()
q.Set("params", string(data))
q.Set(roundtrip.AccessTokenQueryParam, pack.session.Token)
u.RawQuery = q.Encode()
dialer := websocket.Dialer{}
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
header := http.Header{}
header.Add("Origin", "http://localhost")
for _, cookie := range pack.cookies {
header.Add("Cookie", cookie.String())
}
ws, resp, err := dialer.Dial(u.String(), header)
if err != nil {
return nil, nil, trace.Wrap(err)
}
ty, raw, err := ws.ReadMessage()
if err != nil {
return nil, nil, trace.Wrap(err)
}
require.Equal(t, websocket.BinaryMessage, ty)
var env Envelope
err = proto.Unmarshal(raw, &env)
if err != nil {
return nil, nil, trace.Wrap(err)
}
var sessResp siteSessionGenerateResponse
err = json.Unmarshal([]byte(env.Payload), &sessResp)
if err != nil {
return nil, nil, trace.Wrap(err)
}
err = resp.Body.Close()
if err != nil {
return nil, nil, trace.Wrap(err)
}
return ws, &sessResp.Session, nil
}
func waitForCommandOutput(stream io.Reader, substr string) error {
timeoutCh := time.After(10 * time.Second)
for {
select {
case <-timeoutCh:
return trace.BadParameter("timeout waiting on terminal for output: %v", substr)
default:
}
var env outEnvelope
dec := json.NewDecoder(stream)
if err := dec.Decode(&env); err != nil {
return trace.Wrap(err, "decoding envelope JSON from stream")
}
data := removeSpace(string(env.Payload))
if strings.Contains(data, substr) {
return nil
}
}
}
// Test_runCommands tests that runCommands runs the given command on all hosts.
// The commands should run in parallel, but we don't have a deterministic way to
// test that (sleep with checking the execution time in not deterministic).
func Test_runCommands(t *testing.T) {
const numWorkers = 30
counter := atomic.Int32{}
runCmd := func(host *hostInfo) error {
counter.Add(1)
return nil
}
hosts := make([]hostInfo, 0, 100)
for i := 0; i < 100; i++ {
hosts = append(hosts, hostInfo{
hostName: fmt.Sprintf("localhost%d", i),
})
}
logger := logrus.New()
logger.Out = io.Discard
runCommands(hosts, runCmd, numWorkers, logger)
require.Equal(t, int32(100), counter.Load())
}
func mockOpenAISummary(t *testing.T) *httptest.Server {
responses := []string{"This is the summary of the command."}
server := httptest.NewServer(testutils.GetTestHandlerFn(t, responses))
t.Cleanup(server.Close)
return server
}
func messagesByType(messages []*assist.AssistantMessage) map[assistlib.MessageType][]*assist.AssistantMessage {
byType := make(map[assistlib.MessageType][]*assist.AssistantMessage)
for _, message := range messages {
byType[assistlib.MessageType(message.GetType())] = append(byType[assistlib.MessageType(message.GetType())], message)
}
return byType
}
// wsReader implements io.Reader interface over websocket connection
type wsReader struct {
conn *websocket.Conn
}
// Read reads data from websocket connection.
// The message should be in web.Envelope format and only the payload will be returned.
// It expects that the passed buffer is big enough to fit the whole message.
func (r *wsReader) Read(p []byte) (int, error) {
_, data, err := r.conn.ReadMessage()
if err != nil {
return 0, trace.Wrap(err)
}
var envelope Envelope
if err := proto.Unmarshal(data, &envelope); err != nil {
return 0, trace.Errorf("Unable to parse message payload %v", err)
}
if len(envelope.Payload) > len(p) {
return 0, trace.BadParameter("buffer too small")
}
return copy(p, envelope.Payload), nil
}