mirror of
https://github.com/gravitational/teleport
synced 2024-10-18 16:24:03 +00:00
Hosted plugin manager prerequisites (#23922)
* Expose Ping() in bare auth server * Handle both pointer and bare PluginStatusV1 * Add metric name * Add StatusSink * Run GCI * Move comment back to auth_with_roles * Update lib/auth/auth.go Co-authored-by: Alan Parra <alan.parra@goteleport.com> * Rework SetStatus * Inline TryEmitStatus and use a proper context * Fix copyright notice * Fix bug in statusFromStatusCode * Test statusFromResponse * Add link to Slack API schema * Refactor statusFromStatusCode * Expand comment for Ping() * Add basic check for status in slack test * Address nits --------- Co-authored-by: Alan Parra <alan.parra@goteleport.com>
This commit is contained in:
parent
548a4eaed3
commit
aec3669d17
|
@ -216,11 +216,8 @@ func (p *PluginV1) SetStatus(status PluginStatus) error {
|
|||
p.Status = PluginStatusV1{}
|
||||
return nil
|
||||
}
|
||||
switch status := status.(type) {
|
||||
case PluginStatusV1:
|
||||
p.Status = status
|
||||
default:
|
||||
return trace.BadParameter("unsupported plugin status type %T", status)
|
||||
p.Status = PluginStatusV1{
|
||||
Code: status.GetCode(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
28
integrations/access/common/status.go
Normal file
28
integrations/access/common/status.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
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 common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
)
|
||||
|
||||
// StatusSink defines a destination for PluginStatus
|
||||
type StatusSink interface {
|
||||
Emit(ctx context.Context, s types.PluginStatus) error
|
||||
}
|
|
@ -24,6 +24,7 @@ import (
|
|||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/gravitational/trace"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/integrations/access/common"
|
||||
|
@ -33,6 +34,7 @@ import (
|
|||
|
||||
const slackMaxConns = 100
|
||||
const slackHTTPTimeout = 10 * time.Second
|
||||
const statusEmitTimeout = 10 * time.Second
|
||||
|
||||
// Bot is a slack client that works with AccessRequest.
|
||||
// It's responsible for formatting and posting a message on Slack when an
|
||||
|
@ -45,21 +47,38 @@ type Bot struct {
|
|||
}
|
||||
|
||||
// onAfterResponseSlack resty error function for Slack
|
||||
func onAfterResponseSlack(_ *resty.Client, resp *resty.Response) error {
|
||||
if !resp.IsSuccess() {
|
||||
return trace.Errorf("slack api returned unexpected code %v", resp.StatusCode())
|
||||
}
|
||||
func onAfterResponseSlack(sink common.StatusSink) func(_ *resty.Client, resp *resty.Response) error {
|
||||
return func(_ *resty.Client, resp *resty.Response) error {
|
||||
status := statusFromStatusCode(resp.StatusCode())
|
||||
defer func() {
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var result APIResponse
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
// No context in scope, use background with a reasonable timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), statusEmitTimeout)
|
||||
defer cancel()
|
||||
if err := sink.Emit(ctx, status); err != nil {
|
||||
log.Errorf("Error while emitting plugin status: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if !result.Ok {
|
||||
return trace.Errorf("%s", result.Error)
|
||||
}
|
||||
if !resp.IsSuccess() {
|
||||
return trace.Errorf("slack api returned unexpected code %v", resp.StatusCode())
|
||||
}
|
||||
|
||||
return nil
|
||||
var result APIResponse
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
status = statusFromResponse(&result)
|
||||
|
||||
if !result.Ok {
|
||||
return trace.Errorf("%s", result.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b Bot) CheckHealth(ctx context.Context) error {
|
||||
|
|
|
@ -35,6 +35,7 @@ type Config struct {
|
|||
common.BaseConfig
|
||||
Slack common.GenericAPIConfig
|
||||
AccessTokenProvider auth.AccessTokenProvider
|
||||
StatusSink common.StatusSink
|
||||
}
|
||||
|
||||
// LoadSlackConfig reads the config file, initializes a new SlackConfig struct object, and returns it.
|
||||
|
@ -125,7 +126,7 @@ func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot,
|
|||
r.SetHeader("Authorization", "Bearer "+token)
|
||||
return nil
|
||||
}).
|
||||
OnAfterResponse(onAfterResponseSlack)
|
||||
OnAfterResponse(onAfterResponseSlack(c.StatusSink))
|
||||
|
||||
return Bot{
|
||||
client: client,
|
||||
|
|
|
@ -14,7 +14,13 @@
|
|||
|
||||
package slack
|
||||
|
||||
import "github.com/gravitational/teleport/integrations/access/common"
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
"github.com/gravitational/teleport/integrations/access/common"
|
||||
)
|
||||
|
||||
type SlackMessageSlice []Message
|
||||
type SlackDataMessageSet map[common.MessageData]struct{}
|
||||
|
@ -42,3 +48,20 @@ func (set SlackDataMessageSet) Contains(msg common.MessageData) bool {
|
|||
_, ok := set[msg]
|
||||
return ok
|
||||
}
|
||||
|
||||
type fakeStatusSink struct {
|
||||
status atomic.Pointer[types.PluginStatus]
|
||||
}
|
||||
|
||||
func (s *fakeStatusSink) Emit(_ context.Context, status types.PluginStatus) error {
|
||||
s.status.Store(&status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStatusSink) Get() types.PluginStatus {
|
||||
status := s.status.Load()
|
||||
if status == nil {
|
||||
panic("expected status to be set, but it has not been")
|
||||
}
|
||||
return *status
|
||||
}
|
||||
|
|
|
@ -54,8 +54,9 @@ type SlackSuite struct {
|
|||
reviewer2 string
|
||||
plugin string
|
||||
}
|
||||
raceNumber int
|
||||
fakeSlack *FakeSlack
|
||||
raceNumber int
|
||||
fakeSlack *FakeSlack
|
||||
fakeStatusSink *fakeStatusSink
|
||||
|
||||
clients map[string]*integration.Client
|
||||
teleportFeatures *proto.Features
|
||||
|
@ -190,11 +191,14 @@ func (s *SlackSuite) SetupTest() {
|
|||
|
||||
s.fakeSlack.StoreUser(User{Name: "Vladimir", Profile: UserProfile{Email: s.userNames.requestor}})
|
||||
|
||||
s.fakeStatusSink = &fakeStatusSink{}
|
||||
|
||||
var conf Config
|
||||
conf.Teleport = s.teleportConfig
|
||||
conf.Slack.Token = "000000"
|
||||
conf.Slack.APIURL = s.fakeSlack.URL() + "/"
|
||||
conf.AccessTokenProvider = auth.NewStaticAccessTokenProvider(conf.Slack.Token)
|
||||
conf.StatusSink = s.fakeStatusSink
|
||||
|
||||
s.appConfig = &conf
|
||||
s.SetContextTimeout(5 * time.Second)
|
||||
|
@ -313,6 +317,8 @@ func (s *SlackSuite) TestMessagePosting() {
|
|||
statusLine, err := getStatusLine(messages[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "*Status*: ⏳ PENDING", statusLine)
|
||||
|
||||
assert.Equal(t, types.PluginStatusCode_RUNNING, s.fakeStatusSink.Get().GetCode())
|
||||
}
|
||||
|
||||
func (s *SlackSuite) TestRecipientsConfig() {
|
||||
|
|
55
integrations/access/slack/status.go
Normal file
55
integrations/access/slack/status.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
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 slack
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
)
|
||||
|
||||
func statusFromStatusCode(httpCode int) types.PluginStatus {
|
||||
var code types.PluginStatusCode
|
||||
switch {
|
||||
case httpCode == http.StatusUnauthorized:
|
||||
code = types.PluginStatusCode_UNAUTHORIZED
|
||||
case httpCode >= 200 && httpCode < 400:
|
||||
code = types.PluginStatusCode_RUNNING
|
||||
default:
|
||||
code = types.PluginStatusCode_OTHER_ERROR
|
||||
}
|
||||
return &types.PluginStatusV1{Code: code}
|
||||
}
|
||||
|
||||
// statusFromResponse tries to map a Slack API error string
|
||||
// to a PluginStatus.
|
||||
//
|
||||
// Ref: https://github.com/slackapi/slack-api-specs/blob/bc08db49625630e3585bf2f1322128ea04f2a7f3/web-api/slack_web_openapi_v2.json
|
||||
func statusFromResponse(resp *APIResponse) types.PluginStatus {
|
||||
if resp.Ok {
|
||||
return &types.PluginStatusV1{Code: types.PluginStatusCode_RUNNING}
|
||||
}
|
||||
|
||||
code := types.PluginStatusCode_OTHER_ERROR
|
||||
switch resp.Error {
|
||||
case "channel_not_found", "not_in_channel":
|
||||
code = types.PluginStatusCode_SLACK_NOT_IN_CHANNEL
|
||||
case "token_expired", "not_authed", "invalid_auth", "account_inactive", "token_revoked", "no_permission", "org_login_required":
|
||||
code = types.PluginStatusCode_UNAUTHORIZED
|
||||
}
|
||||
return &types.PluginStatusV1{Code: code}
|
||||
}
|
93
integrations/access/slack/status_test.go
Normal file
93
integrations/access/slack/status_test.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
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 slack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
)
|
||||
|
||||
func TestStatusFromStatusCode(t *testing.T) {
|
||||
testCases := []struct {
|
||||
httpCode int
|
||||
want types.PluginStatusCode
|
||||
}{
|
||||
{
|
||||
httpCode: http.StatusOK,
|
||||
want: types.PluginStatusCode_RUNNING,
|
||||
},
|
||||
{
|
||||
httpCode: http.StatusNoContent,
|
||||
want: types.PluginStatusCode_RUNNING,
|
||||
},
|
||||
|
||||
{
|
||||
httpCode: http.StatusUnauthorized,
|
||||
want: types.PluginStatusCode_UNAUTHORIZED,
|
||||
},
|
||||
{
|
||||
httpCode: http.StatusInternalServerError,
|
||||
want: types.PluginStatusCode_OTHER_ERROR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%d", tc.httpCode), func(t *testing.T) {
|
||||
require.Equal(t, tc.want, statusFromStatusCode(tc.httpCode).GetCode())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromResponse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
response *APIResponse
|
||||
want types.PluginStatusCode
|
||||
}{
|
||||
{
|
||||
name: "ok",
|
||||
response: &APIResponse{Ok: true},
|
||||
want: types.PluginStatusCode_RUNNING,
|
||||
},
|
||||
{
|
||||
name: "not_in_channel",
|
||||
response: &APIResponse{Error: "not_in_channel"},
|
||||
want: types.PluginStatusCode_SLACK_NOT_IN_CHANNEL,
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
response: &APIResponse{Error: "token_revoked"},
|
||||
want: types.PluginStatusCode_UNAUTHORIZED,
|
||||
},
|
||||
{
|
||||
name: "other",
|
||||
response: &APIResponse{Error: "some_error"},
|
||||
want: types.PluginStatusCode_OTHER_ERROR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, statusFromResponse(tc.response).GetCode())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -4441,6 +4441,25 @@ func (a *Server) SubmitUsageEvent(ctx context.Context, req *proto.SubmitUsageEve
|
|||
return nil
|
||||
}
|
||||
|
||||
// Ping gets basic info about the auth server.
|
||||
// Please note that Ping is publicly accessible (not protected by any RBAC) by design,
|
||||
// and thus PingResponse must never contain any sensitive information.
|
||||
func (a *Server) Ping(ctx context.Context) (proto.PingResponse, error) {
|
||||
cn, err := a.GetClusterName()
|
||||
if err != nil {
|
||||
return proto.PingResponse{}, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return proto.PingResponse{
|
||||
ClusterName: cn.GetClusterName(),
|
||||
ServerVersion: teleport.Version,
|
||||
ServerFeatures: modules.GetModules().Features().ToProto(),
|
||||
ProxyPublicAddr: a.getProxyPublicAddr(),
|
||||
IsBoring: modules.GetModules().IsBoringBinary(),
|
||||
LoadAllCAs: a.loadAllCAs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type maintenanceWindowCacheKey struct {
|
||||
key string
|
||||
}
|
||||
|
@ -5092,6 +5111,25 @@ func (a *Server) CompareAndSwapHeadlessAuthentication(ctx context.Context, old,
|
|||
return headlessAuthn, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// getProxyPublicAddr returns the first valid, non-empty proxy public address it
|
||||
// finds, or empty otherwise.
|
||||
func (a *Server) getProxyPublicAddr() string {
|
||||
if proxies, err := a.GetProxies(); err == nil {
|
||||
for _, p := range proxies {
|
||||
addr := p.GetPublicAddr()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := utils.ParseAddr(addr); err != nil {
|
||||
log.Warningf("Invalid public address on the proxy %q: %q: %v.", p.GetName(), addr, err)
|
||||
continue
|
||||
}
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authKeepAliver is a keep aliver using auth server directly
|
||||
type authKeepAliver struct {
|
||||
sync.RWMutex
|
||||
|
|
|
@ -2212,37 +2212,7 @@ func (a *ServerWithRoles) Ping(ctx context.Context) (proto.PingResponse, error)
|
|||
// The Ping method does not require special permissions since it only returns
|
||||
// basic status information. This is an intentional design choice. Alternative
|
||||
// methods should be used for relaying any sensitive information.
|
||||
cn, err := a.authServer.GetClusterName()
|
||||
if err != nil {
|
||||
return proto.PingResponse{}, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return proto.PingResponse{
|
||||
ClusterName: cn.GetClusterName(),
|
||||
ServerVersion: teleport.Version,
|
||||
ServerFeatures: modules.GetModules().Features().ToProto(),
|
||||
ProxyPublicAddr: a.getProxyPublicAddr(),
|
||||
IsBoring: modules.GetModules().IsBoringBinary(),
|
||||
LoadAllCAs: a.authServer.loadAllCAs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getProxyPublicAddr gets the server's public proxy address.
|
||||
func (a *ServerWithRoles) getProxyPublicAddr() string {
|
||||
if proxies, err := a.authServer.GetProxies(); err == nil {
|
||||
for _, p := range proxies {
|
||||
addr := p.GetPublicAddr()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := utils.ParseAddr(addr); err != nil {
|
||||
log.Warningf("Invalid public address on the proxy %q: %q: %v.", p.GetName(), addr, err)
|
||||
continue
|
||||
}
|
||||
return addr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return a.authServer.Ping(ctx)
|
||||
}
|
||||
|
||||
func (a *ServerWithRoles) DeleteAccessRequest(ctx context.Context, name string) error {
|
||||
|
|
|
@ -210,6 +210,10 @@ const (
|
|||
// MetricReverseSSHTunnels defines the number of connected SSH reverse tunnels to the proxy
|
||||
MetricReverseSSHTunnels = "reverse_tunnels_connected"
|
||||
|
||||
// MetricHostedPluginStatus tracks the current status
|
||||
// (as defined by types.PluginStatus) for a plugin instance
|
||||
MetricHostedPluginStatus = "hosted_plugin_status"
|
||||
|
||||
// TagRange is a tag specifying backend requests
|
||||
TagRange = "range"
|
||||
|
||||
|
|
Loading…
Reference in a new issue