diff --git a/api/utils/prompt/confirmation.go b/api/utils/prompt/confirmation.go index 5d3cb19739c..a8fb2d07e0f 100644 --- a/api/utils/prompt/confirmation.go +++ b/api/utils/prompt/confirmation.go @@ -21,6 +21,7 @@ import ( "context" "fmt" "io" + urlpkg "net/url" "strings" "github.com/gravitational/trace" @@ -108,3 +109,45 @@ func Password(ctx context.Context, out io.Writer, in SecureReader, question stri } return string(answer), nil // passwords not trimmed } + +// URLOptions are options for the URL prompt. +type URLOptions func(*urlOptions) + +type urlOptions struct { + urlValidator func(*urlpkg.URL) error +} + +// WithURLValidator sets a custom URL validator for the URL prompt. +func WithURLValidator(validator func(*urlpkg.URL) error) URLOptions { + return func(opts *urlOptions) { + opts.urlValidator = validator + } +} + +// URL prompts the user for a URL. The prompt is written to out and the answer. +func URL(ctx context.Context, out io.Writer, in Reader, question string, opts ...URLOptions) (string, error) { + url, err := Input(ctx, out, in, question) + if err != nil { + return "", trace.Wrap(err, "failed reading prompt response") + } + + url = strings.TrimSpace(url) + + u, err := urlpkg.Parse(url) + if err != nil { + return "", trace.Wrap(err, "invalid URL") + } + + opt := &urlOptions{} + for _, o := range opts { + o(opt) + } + + if opt.urlValidator != nil { + if err := opt.urlValidator(u); err != nil { + return "", trace.Wrap(err, "failed to verify url") + } + } + + return url, nil +} diff --git a/api/utils/prompt/context_reader_test.go b/api/utils/prompt/context_reader_test.go index 8e6fae75e20..adc5c3ee8dd 100644 --- a/api/utils/prompt/context_reader_test.go +++ b/api/utils/prompt/context_reader_test.go @@ -17,12 +17,17 @@ limitations under the License. package prompt import ( + "bytes" "context" "io" + "net/http" + "net/http/httptest" + urlpkg "net/url" "os" "testing" "time" + "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/term" @@ -271,3 +276,82 @@ func (t *fakeTerm) Restore(fd int, oldState *term.State) error { t.restoreCalled = true return nil } + +func TestURL(t *testing.T) { + pr, pw := io.Pipe() + t.Cleanup(func() { pr.Close() }) + t.Cleanup(func() { pw.Close() }) + httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) + })) + t.Cleanup(httpSrv.Close) + + write := func(t *testing.T, s string) { + t.Helper() + _, err := pw.Write([]byte(s)) + assert.NoError(t, err, "Write failed") + } + + ctx := context.Background() + cr := NewContextReader(pr) + + t.Run("simple read", func(t *testing.T) { + go write(t, httpSrv.URL) + out := &bytes.Buffer{} + gotURL, err := URL(ctx, out, cr, "Enter URL") + require.NoError(t, err) + require.Equal(t, httpSrv.URL, gotURL) + require.Equal(t, "Enter URL: ", out.String()) + }) + + t.Run("read with validator", func(t *testing.T) { + go write(t, httpSrv.URL) + out := &bytes.Buffer{} + + gotURL, err := URL(ctx, out, cr, "Enter URL", WithURLValidator(func(u *urlpkg.URL) error { + rspBody, err := doHTTPCall(ctx, u.String()) + if err != nil { + return trace.Wrap(err) + } + if rspBody != "hello" { + return trace.BadParameter("unexpected response body: %q", rspBody) + } + return nil + })) + require.NoError(t, err) + require.Equal(t, httpSrv.URL, gotURL) + require.Equal(t, "Enter URL: ", out.String()) + }) + + t.Run("read with failed validator", func(t *testing.T) { + go write(t, httpSrv.URL) + out := &bytes.Buffer{} + + _, err := URL(ctx, out, cr, "Enter URL", WithURLValidator(func(u *urlpkg.URL) error { + rspBody, err := doHTTPCall(ctx, u.String()) + if err != nil { + return trace.Wrap(err) + } + if rspBody != "notHello" { + return trace.BadParameter("unexpected response body: %q", rspBody) + } + return nil + })) + require.Error(t, err) + }) +} + +func doHTTPCall(ctx context.Context, u string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", trace.Wrap(err) + } + rsp, err := http.DefaultClient.Do(req) + if err != nil { + return "", trace.Wrap(err) + } + defer rsp.Body.Close() + defer io.Copy(io.Discard, rsp.Body) + b, err := io.ReadAll(rsp.Body) + return string(b), trace.Wrap(err) +} diff --git a/tool/tctl/common/plugin/netiq.go b/tool/tctl/common/plugin/netiq.go new file mode 100644 index 00000000000..66bdb891317 --- /dev/null +++ b/tool/tctl/common/plugin/netiq.go @@ -0,0 +1,520 @@ +/* + * 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 . + */ + +package plugin + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/alecthomas/kingpin/v2" + "github.com/fatih/color" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api" + pluginspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/prompt" + "github.com/gravitational/teleport/lib/defaults" +) + +var ( + boldGreen = color.New(color.Bold, color.FgGreen).SprintFunc() + netIQStep1Template = bold("Step 1: Configure IDM OSP address") + ` + +Please provide the IDM OSP address to configure the integration. +For example, 'https://idm.example.com/osp' or 'https://osp.idm.example.com'. +` + netIQStep2Template = bold("Step 2: Configure IDM API address") + ` +Please provide the IDM API (IDMProv or IDM Identity Applications) address to configure the integration. +For example, 'https://idm.example.com/IDMProv' or 'https://idmapps.idm.example.com'. +` + netIQStep3Template = bold("Step 3: Configure IDM OSP OAuth Client") + ` + +The easiest way to register a new OAuth client with OSP (IDM Authorization Server) is to edit the ` + bold("ism-configuration.properties") + ` file by hand. +The file is located in the ` + bold("osp-path/tomcat/conf/") + ` directory. +To use an OAuth client with OSP, you must configure the following properties: +` + boldGreen(` com.example..clientID = + com.example..clientPass = +`) + ` + +If you prefer to store the client secret in a secure way, you can run the following command: + + ` + boldGreen(`java -jar /opt/netiq/idm/apps/tomcat/lib/obscurity-*jar `) + ` + +Then, copy the output and paste it into the ` + boldGreen(`com.example..clientPass`) + ` property as shown below: +` + boldGreen(` com.example..clientID = + com.example..clientPass._attr_obscurity = ENCRYPT + com.example..clientPass = +`) + ` + +After configuring the OAuth client, please restart OSP to apply the changes and type 'continue' to proceed. + +` + netIQStep4Template = bold("Step 4: Input Client ID and Client Secret") + ` + +With the values used in Step 2, please copy and paste the following information: +` + netIQStep5Template = bold("Step 5: Input IDM User and Password") + ` + +Please provide the IDM user to configure the integration. +This user must have permissions to access the IDM API and retrieve users, groups, roles +and resources. Use the following format: ` + bold("cn=uaadmin,ou=sa,o=data") + `. +` +) + +type netIQArgs struct { + cmd *kingpin.CmdClause + insecureSkipVerify bool +} + +func (p *PluginsCommand) initInstallNetIQ(parent *kingpin.CmdClause) { + p.install.netIQ.cmd = parent.Command("netiq", "Install an Access Graph NetIQ integration.") + cmd := p.install.netIQ.cmd + cmd.Flag("insecure-skip-verify", "Skip verification of the NetIQ server's SSL certificate.").BoolVar(&p.install.netIQ.insecureSkipVerify) +} + +type netIQSettings struct { + apiURL string + ospURL string + identityVaultUser string + identityVaultPassword string + oAuthClientID string + oAuthClientSecret string + insecureSkipVerify bool +} + +func (p *PluginsCommand) netIQSetupGuide(ctx context.Context) (netIQSettings, error) { + settings := netIQSettings{} + var err error + + settings.ospURL, err = promptForURL( + ctx, + os.Stdout, + netIQStep1Template, + "Please enter the IDM OSP address", + p.install.netIQ.insecureSkipVerify, + func(ctx context.Context, s *url.URL, b bool) error { + _, err := checkNetIQOSPAddress(ctx, s.String(), b) + return trace.Wrap(err) + }) + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + settings.apiURL, err = promptForURL( + ctx, + os.Stdout, + netIQStep2Template, + "Please enter the IDM API address", + p.install.netIQ.insecureSkipVerify, + func(ctx context.Context, u *url.URL, b bool) error { + return checkUnauthenticatedNetIQAPIAddress(ctx, u.String(), b) + }) + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + if err := promptForContinue(ctx, os.Stdout, netIQStep3Template); err != nil { + return netIQSettings{}, err + } + + settings.oAuthClientID, err = promptForInput(ctx, os.Stdout, netIQStep4Template, "Enter the OAuth ClientID") + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + settings.oAuthClientSecret, err = promptForPassword(ctx, os.Stdout, "Enter the OAuth Client Secret") + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + settings.identityVaultUser, err = promptForInput(ctx, os.Stdout, netIQStep5Template, "Enter the IDM User") + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + settings.identityVaultPassword, err = promptForPassword(ctx, os.Stdout, "Enter the IDM User Password") + if err != nil { + return netIQSettings{}, trace.Wrap(err) + } + + settings.insecureSkipVerify = p.install.netIQ.insecureSkipVerify + + // Validate the NetIQ settings. + if err := checkAuthenticatedNetIQAPIAddress(ctx, settings); err != nil { + return netIQSettings{}, trace.Wrap(err) + } + return settings, nil +} + +func (p *PluginsCommand) InstallNetIQ(ctx context.Context, args installPluginArgs) error { + settings, err := p.netIQSetupGuide(ctx) + if err != nil { + if errors.Is(err, errCancel) { + return nil + } + return trace.Wrap(err) + } + + plugin, err := createNetIQPlugin(&settings) + if err != nil { + return trace.Wrap(err, "failed to create NetIQ plugin") + } + + creds, err := getNetIQPluginCredentials(settings) + if err != nil { + return trace.Wrap(err, "failed to get NetIQ plugin credentials") + } + + createPluginRequest := &pluginspb.CreatePluginRequest{ + Plugin: plugin, + StaticCredentialsList: creds, + CredentialLabels: map[string]string{ + netIQOrgURLLabel: settings.apiURL, + }, + } + + if _, err = args.plugins.CreatePlugin(ctx, createPluginRequest); err != nil { + return trace.Wrap(err, "failed to create NetIQ plugin") + } + + fmt.Println("NetIQ plugin has been successfully installed.") + + return nil +} + +func promptForURL(ctx context.Context, out io.Writer, template, promptT string, insecureSkipVerify bool, checkFunc func(context.Context, *url.URL, bool) error) (string, error) { + fmt.Fprintf(out, "\n%s", template) + + return prompt.URL( + ctx, + out, + prompt.Stdin(), + promptT, + prompt.WithURLValidator( + func(u *url.URL) error { + if err := checkFunc(ctx, u, insecureSkipVerify); err != nil { + fmt.Fprintf(out, "Invalid address: %v\n", err) + return trace.Wrap(err) + } + return nil + }, + ), + ) +} + +func promptForContinue(ctx context.Context, out io.Writer, template string) error { + fmt.Fprintf(out, "\n%s", template) + op, err := prompt.PickOne( + ctx, + out, + prompt.Stdin(), + "Type 'continue' to proceed, 'exit' to quit", + []string{"continue", "exit"}, + ) + if err != nil { + return trace.Wrap(err) + } + if op == "exit" { + return errCancel + } + return nil +} + +func promptForPassword(ctx context.Context, out io.Writer, promptMsg string) (string, error) { + return prompt.Password( + ctx, out, prompt.Stdin(), + promptMsg, + ) +} + +func promptForInput(ctx context.Context, out io.Writer, template, promptT string) (string, error) { + if template != "" { + fmt.Fprintf(out, "\n%s", template) + } + return prompt.Input(ctx, out, prompt.Stdin(), promptT) +} + +func checkNetIQOSPAddress(ctx context.Context, ospURL string, insecureSkipVerify bool) (string, error) { + var tokenAddr string + err := checkNetIQAddress( + ctx, + ospURL, + "/a/idm/auth/oauth2/.well-known/openid-configuration", + insecureSkipVerify, + func(r *http.Response) error { + type openIDConfig struct { + Token string `json:"token_endpoint"` + } + out := openIDConfig{} + if err := json.NewDecoder(r.Body).Decode(&out); err != nil || out.Token == "" { + return trace.BadParameter("invalid OSP address") + } + tokenAddr = out.Token + return nil + }, + ) + return tokenAddr, trace.Wrap(err) +} + +// checkUnauthenticatedNetIQAPIAddress checks if the NetIQ API address is reachable and serves the expected content. +func checkUnauthenticatedNetIQAPIAddress(ctx context.Context, apiAddr string, insecureSkipVerify bool) error { + return checkNetIQAddress( + ctx, + apiAddr, + "rest/access/info/version", + insecureSkipVerify, + // API endpoints are protected so we are ensuring that the endpoint + // is reachable and doesn't return 404. + func(r *http.Response) error { + if r.StatusCode != http.StatusUnauthorized { + return trace.BadParameter("invalid API address") + } + return nil + }, + ) +} + +func checkAuthenticatedNetIQAPIAddress(ctx context.Context, data netIQSettings) error { + tokenUrl, err := checkNetIQOSPAddress(ctx, data.ospURL, data.insecureSkipVerify) + if err != nil { + return trace.Wrap(err) + } + + q := url.Values{} + q.Add("grant_type", "password") + q.Add("username", data.identityVaultUser) + q.Add("password", data.identityVaultPassword) + + // validate API credentials + rsp, err := doRequest( + ctx, + tokenUrl, + http.MethodPost, + data.insecureSkipVerify, + withBody(strings.NewReader(q.Encode())), + withBasicAuth(data.oAuthClientID, data.oAuthClientSecret), + withHeader("Content-Type", "application/x-www-form-urlencoded"), + ) + + if err != nil { + return trace.Wrap(err) + } + + defer rsp.Body.Close() + defer io.Copy(io.Discard, rsp.Body) + + // tokenResponse represents the response from the OSP(NetIQ authorization service) token endpoint + // when requesting an access token. + type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + var token tokenResponse + if err := json.NewDecoder(rsp.Body).Decode(&token); err != nil { + return trace.Wrap(err, "failed to decode response") + } + + return checkNetIQAddress( + ctx, + data.apiURL, + "rest/access/info/version", + data.insecureSkipVerify, + // API endpoints are protected so we are ensuring that the endpoint + // is reachable and doesn't return 404. + func(r *http.Response) error { + switch r.StatusCode { + case http.StatusUnauthorized: + return trace.BadParameter("invalid API credentials") + case http.StatusOK: + return nil + default: + return trace.BadParameter("invalid API address") + } + }, + withHeader("Authorization", fmt.Sprintf("%s %s", token.TokenType, token.AccessToken)), + ) +} + +func checkNetIQAddress(ctx context.Context, addr, path string, insecureSkipVerify bool, validation func(*http.Response) error, opts ...doRequestOptions) error { + u, err := url.Parse(addr) + if err != nil { + return trace.Wrap(err, "invalid address") + } + u = u.JoinPath(path) + rsp, err := doRequest(ctx, u.String(), http.MethodGet, insecureSkipVerify, opts...) + if err != nil { + return trace.Wrap(err, "failed to check address") + } + defer rsp.Body.Close() + defer io.Copy(io.Discard, rsp.Body) + + if err := validation(rsp); err != nil { + return trace.Wrap(err, "failed to validate address") + } + + return nil +} + +type doRequestOptions func(*http.Request) error + +func withBasicAuth(user, password string) doRequestOptions { + return func(req *http.Request) error { + req.SetBasicAuth(user, password) + return nil + } +} + +func withBody(body io.Reader) doRequestOptions { + return func(req *http.Request) error { + req.Body = io.NopCloser(body) + return nil + } +} + +func withHeader(key, value string) doRequestOptions { + return func(req *http.Request) error { + req.Header.Set(key, value) + return nil + } +} + +func doRequest(ctx context.Context, url, method string, insecureSkipVerify bool, opts ...doRequestOptions) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + return nil, trace.Wrap(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("%s/%s", teleport.ComponentTCTL, api.Version)) + + for _, opt := range opts { + if err := opt(req); err != nil { + return nil, trace.Wrap(err) + } + } + + transport, err := defaults.Transport() + if err != nil { + return nil, trace.Wrap(err) + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.InsecureSkipVerify = insecureSkipVerify + client := &http.Client{ + Transport: transport, + } + return client.Do(req) +} + +func getNetIQPluginCredentials(req netIQSettings) ([]*types.PluginStaticCredentialsV1, error) { + var out []*types.PluginStaticCredentialsV1 + if req.oAuthClientID != "" { + out = append(out, buildOAuthCredentials(req.oAuthClientID, req.oAuthClientSecret)) + } + if req.identityVaultUser != "" { + out = append(out, buildBasicAuthCredentials(req.identityVaultUser, req.identityVaultPassword)) + } + return out, nil +} + +const ( + // netIQOrgURLLabel is the label for which NetIQ instance object belongs to. + netIQOrgURLLabel = "netiq/org" + // credPurposeLabel is the label for the purpose of the credential. + credPurposeLabel = "netiq/purpose" + // credPurposeNetIQOauth is the purpose for the NetIQ OAuth client ID and secret. + credPurposeNetIQOauth = "oauth-client-id-secret" + // credPurposeNetIQAuth is the purpose for the NetIQ Identity Vault user and password. + credPurposeNetIQAuth = "netiq-auth" +) + +func buildOAuthCredentials(clientID, clientSecret string) *types.PluginStaticCredentialsV1 { + return &types.PluginStaticCredentialsV1{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: types.PluginTypeNetIQ + "-oauth", + Labels: map[string]string{ + credPurposeLabel: credPurposeNetIQOauth, + }, + }, + }, + Spec: &types.PluginStaticCredentialsSpecV1{ + Credentials: &types.PluginStaticCredentialsSpecV1_OAuthClientSecret{ + OAuthClientSecret: &types.PluginStaticCredentialsOAuthClientSecret{ + ClientId: clientID, + ClientSecret: clientSecret, + }, + }, + }, + } +} + +func buildBasicAuthCredentials(user, password string) *types.PluginStaticCredentialsV1 { + return &types.PluginStaticCredentialsV1{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: types.PluginTypeNetIQ + "-basic-auth", + Labels: map[string]string{ + credPurposeLabel: credPurposeNetIQAuth, + }, + }, + }, + Spec: &types.PluginStaticCredentialsSpecV1{ + Credentials: &types.PluginStaticCredentialsSpecV1_BasicAuth{ + BasicAuth: &types.PluginStaticCredentialsBasicAuth{ + Username: user, + Password: password, + }, + }, + }, + } +} + +func createNetIQPlugin(params *netIQSettings) (*types.PluginV1, error) { + return &types.PluginV1{ + SubKind: types.PluginSubkindAccessGraph, + Metadata: types.Metadata{ + Labels: map[string]string{ + types.TeleportNamespace + "/hosted-plugin": "true", + }, + Name: types.PluginTypeNetIQ, + }, + Spec: types.PluginSpecV1{ + Settings: &types.PluginSpecV1_NetIq{ + NetIq: &types.PluginNetIQSettings{ + OauthIssuerEndpoint: params.ospURL, + ApiEndpoint: params.apiURL, + InsecureSkipVerify: params.insecureSkipVerify, + }, + }, + }, + }, nil +} diff --git a/tool/tctl/common/plugin/plugins_command.go b/tool/tctl/common/plugin/plugins_command.go index b6c6ed57d85..b5edc45405d 100644 --- a/tool/tctl/common/plugin/plugins_command.go +++ b/tool/tctl/common/plugin/plugins_command.go @@ -56,6 +56,7 @@ type pluginInstallArgs struct { okta oktaArgs scim scimArgs entraID entraArgs + netIQ netIQArgs } type scimArgs struct { @@ -102,6 +103,7 @@ func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause, config *servicec p.initInstallOkta(p.install.cmd) p.initInstallSCIM(p.install.cmd) p.initInstallEntra(p.install.cmd) + p.initInstallNetIQ(p.install.cmd) } func (p *PluginsCommand) initInstallSCIM(parent *kingpin.CmdClause) { @@ -326,6 +328,8 @@ func (p *PluginsCommand) TryRun(ctx context.Context, cmd string, clientFunc comm commandFunc = p.InstallSCIM case p.install.entraID.cmd.FullCommand(): commandFunc = p.InstallEntra + case p.install.netIQ.cmd.FullCommand(): + commandFunc = p.InstallNetIQ case p.delete.cmd.FullCommand(): commandFunc = p.Delete default: