diff --git a/api/types/provisioning.go b/api/types/provisioning.go index 37cd34f661c9d..c421bf0a31f8c 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -109,6 +109,8 @@ type ProvisionToken interface { GetRoles() SystemRoles // SetRoles sets teleport roles SetRoles(SystemRoles) + // SetLabels sets the tokens labels + SetLabels(map[string]string) // GetAllowRules returns the list of allow rules GetAllowRules() []*TokenRule // SetAllowRules sets the allow rules @@ -351,6 +353,10 @@ func (p *ProvisionTokenV2) SetRoles(r SystemRoles) { p.Spec.Roles = r } +func (p *ProvisionTokenV2) SetLabels(l map[string]string) { + p.Metadata.Labels = l +} + // GetAllowRules returns the list of allow rules func (p *ProvisionTokenV2) GetAllowRules() []*TokenRule { return p.Spec.Allow diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 57a62c393cdca..54b75e83a5e97 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -108,6 +108,13 @@ const ( // assistantLimiterCapacity is the total capacity of the token bucket for the assistant rate limiter. // The bucket starts full, prefilled for a week. assistantLimiterCapacity = assistantTokensPerHour * 24 * 7 + // webUIFlowLabelKey is a label that may be added to resources + // created via the web UI, indicating which flow the resource was created on. + // This label is used for enhancing UX in the web app, by showing icons related, + // to the workflow it was added, or providing unique features to those resources. + // Example values: + // - github-actions-ssh: indicates that the resource was added via the Bot GitHub Actions SSH flow + webUIFlowLabelKey = "teleport.internal/ui-flow" ) // healthCheckAppServerFunc defines a function used to perform a health check @@ -933,6 +940,8 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.getBot)) // Create Machine ID bots h.POST("/webapi/sites/:site/machine-id/bot", h.WithClusterAuth(h.createBot)) + // Create bot join tokens + h.POST("/webapi/sites/:site/machine-id/token", h.WithClusterAuth(h.createBotJoinToken)) } // GetProxyClient returns authenticated auth server client diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 7cdd7d8c4a591..df98fbd0dc840 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -18,16 +18,24 @@ package web import ( "net/http" + "time" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" ) +const ( + // webUIFlowBotGitHubActionsSSH is the value of the webUIFlowLabelKey + // added to a resource created via the Bot GitHub Actions web UI flow. + webUIFlowBotGitHubActionsSSH = "github-actions-ssh" +) + type CreateBotRequest struct { // BotName is the name of the bot BotName string `json:"botName"` @@ -55,6 +63,9 @@ func (h *Handler) createBot(w http.ResponseWriter, r *http.Request, p httprouter Bot: &machineidv1.Bot{ Metadata: &headerv1.Metadata{ Name: req.BotName, + Labels: map[string]string{ + webUIFlowLabelKey: webUIFlowBotGitHubActionsSSH, + }, }, Spec: &machineidv1.BotSpec{ Roles: req.Roles, @@ -69,6 +80,62 @@ func (h *Handler) createBot(w http.ResponseWriter, r *http.Request, p httprouter return OK(), nil } +// CreateBotJoinTokenRequest represents a client request to +// create a bot join token +type CreateBotJoinTokenRequest struct { + // IntegrationName is the name attributed to the bot integration, which + // is used to name the resources created during the UI flow. + IntegrationName string `json:"integrationName"` + // JoinMethod is the joining method required in order to use this token. + JoinMethod types.JoinMethod `json:"joinMethod"` + // GitHub allows the configuration of options specific to the "github" join method. + GitHub *types.ProvisionTokenSpecV2GitHub `json:"gitHub"` + // WebFlowLabel is the value of the label attributed to bots created via the web UI + WebFlowLabel string `json:"webFlowLabel"` +} + +// createBotJoinToken creates a bot join token +func (h *Handler) createBotJoinToken(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + var req *CreateBotJoinTokenRequest + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + + if err := types.ValidateJoinMethod(req.JoinMethod); err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + spec := types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleBot}, + JoinMethod: req.JoinMethod, + GitHub: req.GitHub, + BotName: req.IntegrationName, + } + provisionToken, err := types.NewProvisionTokenFromSpec(req.IntegrationName, time.Time{}, spec) + if err != nil { + return nil, trace.Wrap(err) + } + provisionToken.SetLabels(map[string]string{ + webUIFlowLabelKey: req.WebFlowLabel, + }) + + err = clt.CreateToken(r.Context(), provisionToken) + if err != nil { + return nil, trace.Wrap(err, "error creating join token") + } + + return &nodeJoinToken{ + ID: provisionToken.GetName(), + Expiry: provisionToken.Expiry(), + Method: provisionToken.GetJoinMethod(), + }, nil +} + // getBot retrieves a bot by name func (h *Handler) getBot(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { botName := p.ByName("name") diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 7d80f170e6033..80bb8f7b32855 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -87,6 +87,58 @@ func TestCreateBot(t *testing.T) { require.True(t, trace.IsAccessDenied(err)) } +func TestCreateBotJoinToken(t *testing.T) { + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "token", + ) + + // add github join token + integrationName := "my-app-deploy" + validReq := CreateBotJoinTokenRequest{ + IntegrationName: integrationName, + JoinMethod: types.JoinMethodGitHub, + GitHub: &types.ProvisionTokenSpecV2GitHub{ + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + Actor: "actor", + }, + }, + }, + WebFlowLabel: webUIFlowBotGitHubActionsSSH, + } + resp, err := pack.clt.PostJSON(ctx, endpoint, validReq) + require.NoError(t, err) + + var result nodeJoinToken + json.Unmarshal(resp.Bytes(), &result) + require.Equal(t, integrationName, result.ID) + require.Equal(t, types.JoinMethod("github"), result.Method) + + // invalid join method + invalidJoinMethodReq := validReq + // use a different integration name so it doesn't error for being duplicated + invalidJoinMethodReq.IntegrationName = "invalid-join-method-test" + invalidJoinMethodReq.JoinMethod = "invalid-join-method" + _, err = pack.clt.PostJSON(ctx, endpoint, invalidJoinMethodReq) + require.Error(t, err) + + // no integration name + invalidIntegrationNameReq := validReq + invalidIntegrationNameReq.IntegrationName = "" + _, err = pack.clt.PostJSON(ctx, endpoint, invalidIntegrationNameReq) + require.Error(t, err) +} + func TestGetBotByName(t *testing.T) { ctx := context.Background() env := newWebPack(t, 1)