Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let tctl make plugins #41038

Merged
merged 22 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions api/types/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,28 @@ const (
HostedPluginLabel = TeleportNamespace + "/hosted-plugin"
)

const (
// OktaOrgURLLabel is the label used by Okta-managed resources to indicate
// the upstream Okta organization that they come from.
OktaOrgURLLabel = "okta/org"

// OktaAppIDLabel is the label for the Okta application ID on appserver objects.
OktaAppIDLabel = TeleportInternalLabelPrefix + "okta-app-id"

// OktaCredPurposeLabel is used by Okta-managed PluginStaticCredentials to
// indicate their purpose
OktaCredPurposeLabel = "okta/purpose"

// OktaCredPurposeAuth indicates that the credential is intended for
// authenticating with the Okta REST API
OktaCredPurposeAuth = "okta-auth"

// OktaCredPurposeSCIMToken indicates that theis to be used for authenticating
// SCIM requests from the upstream organization. The content of the credential
// is a bcrypt hash of actual token.
OktaCredPurposeSCIMToken = "scim-bearer-token"
)

const (
// SCIMBaseURLLabel defines a label indicating the base URL for
// interacting with a plugin via SCIM. Useful for diagnostic display.
Expand Down
224 changes: 213 additions & 11 deletions tool/tctl/common/plugins_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import (
"context"
"fmt"
"log/slog"
"net/url"

"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"

"github.com/gravitational/teleport"
pluginsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1"
Expand All @@ -48,9 +51,24 @@ func logErrorMessage(err error) slog.Attr {
type pluginInstallArgs struct {
cmd *kingpin.CmdClause
name string
okta oktaArgs
scim scimArgs
}

type oktaArgs struct {
cmd *kingpin.CmdClause
org *url.URL
appID string
samlConnector string
apiToken string
scimToken string
userSync bool
accessListSync bool
defaultOwners []string
appFilters []string
groupFilters []string
}

type scimArgs struct {
cmd *kingpin.CmdClause
samlConnector string
Expand Down Expand Up @@ -85,13 +103,64 @@ func (p *PluginsCommand) Initialize(app *kingpin.Application, config *servicecfg
p.cleanupCmd.Arg("type", "The type of plugin to cleanup. Only supports okta at present.").Required().EnumVar(&p.pluginType, string(types.PluginTypeOkta))
p.cleanupCmd.Flag("dry-run", "Dry run the cleanup command. Dry run defaults to on.").Default("true").BoolVar(&p.dryRun)

p.initInstall(pluginsCommand)
p.initInstall(pluginsCommand, config)
p.initDelete(pluginsCommand)
}

func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause) {
func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause, config *servicecfg.Config) {
p.install.cmd = parent.Command("install", "Install new plugin instance")

p.initInstallOkta(p.install.cmd)
p.initInstallSCIM(p.install.cmd)
}

func (p *PluginsCommand) initInstallOkta(parent *kingpin.CmdClause) {
p.install.okta.cmd = parent.Command("okta", "Install an okta integration")
p.install.okta.cmd.
Flag("name", "Name of the plugin resource to create").
Default("okta").
StringVar(&p.install.name)
p.install.okta.cmd.
Flag("org", "URL of Okta organization").
Required().
URLVar(&p.install.okta.org)
p.install.okta.cmd.
Flag("api-token", "Okta API token for the plugin to use").
Required().
StringVar(&p.install.okta.apiToken)
p.install.okta.cmd.
Flag("saml-connector", "SAML connector used for Okta SSO login.").
Required().
StringVar(&p.install.okta.samlConnector)
p.install.okta.cmd.
Flag("app-id", "Okta ID of the APP used for SSO via SAML").
StringVar(&p.install.okta.appID)
p.install.okta.cmd.
Flag("scim-token", "Okta SCIM auth token for the plugin to use").
StringVar(&p.install.okta.scimToken)
p.install.okta.cmd.
Flag("sync-users", "Enable user synchronization").
Default("true").
BoolVar(&p.install.okta.userSync)
p.install.okta.cmd.
Flag("owner", "Add default owners for synced Access Lists").
Short('o').
StringsVar(&p.install.okta.defaultOwners)
p.install.okta.cmd.
Flag("sync-groups", "Enable group to Access List synchronization").
Default("true").
BoolVar(&p.install.okta.accessListSync)
p.install.okta.cmd.
Flag("group", "Add a group filter. Supports globbing by default. Enclose in `^pattern$` for full regex support.").
Short('g').
StringsVar(&p.install.okta.groupFilters)
p.install.okta.cmd.
Flag("app", "Add an app filter. Supports globbing by default. Enclose in `^pattern$` for full regex support.").
Short('a').
StringsVar(&p.install.okta.appFilters)
}

func (p *PluginsCommand) initInstallSCIM(parent *kingpin.CmdClause) {
p.install.scim.cmd = p.install.cmd.Command("scim", "Install a new SCIM integration")
p.install.scim.cmd.
Flag("name", "The name of the SCIM plugin resource to create").
Expand Down Expand Up @@ -189,6 +258,142 @@ func (p *PluginsCommand) Cleanup(ctx context.Context, clusterAPI *authclient.Cli
return nil
}

type samlConnectorsClient interface {
GetSAMLConnector(ctx context.Context, id string, withSecrets bool) (types.SAMLConnector, error)
}

type pluginsClient interface {
CreatePlugin(ctx context.Context, in *pluginsv1.CreatePluginRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
}

type installPluginArgs struct {
samlConnectors samlConnectorsClient
plugins pluginsClient
}

func (p *PluginsCommand) InstallOkta(ctx context.Context, args installPluginArgs) error {
log := p.config.Logger.With(logFieldPlugin, p.install.name)
oktaSettings := p.install.okta

if oktaSettings.accessListSync {
if len(oktaSettings.defaultOwners) == 0 {
return trace.BadParameter("AccessList sync requires at least one default owner to be set")
}
}

if oktaSettings.scimToken != "" {
if len(oktaSettings.defaultOwners) == 0 {
return trace.BadParameter("SCIM support requires at least one default owner to be set")
}
}

log.DebugContext(ctx, "Validating SAML Connector...",
logFieldSAMLConnector, oktaSettings.samlConnector)
connector, err := args.samlConnectors.GetSAMLConnector(ctx, oktaSettings.samlConnector, false)
if err != nil {
log.ErrorContext(ctx, "Failed validating SAML connector",
slog.String(logFieldSAMLConnector, oktaSettings.samlConnector),
logErrorMessage(err))
return trace.Wrap(err)
}

if p.install.okta.appID == "" {
log.DebugContext(ctx, "Deducing Okta App ID from SAML Connector...",
logFieldSAMLConnector, oktaSettings.samlConnector)
appID, ok := connector.GetMetadata().Labels[types.OktaAppIDLabel]
if ok {
p.install.okta.appID = appID
}
}

if oktaSettings.scimToken != "" && oktaSettings.appID == "" {
log.ErrorContext(ctx, "SCIM support requires App ID, which was not supplied and couldn't be deduced from the SAML connector")
log.ErrorContext(ctx, "Specify the App ID explicitly with --app-id")
return trace.BadParameter("SCIM support requires app-id to be set")
}

creds := []*types.PluginStaticCredentialsV1{
{
ResourceHeader: types.ResourceHeader{
Metadata: types.Metadata{
Name: p.install.name,
Labels: map[string]string{
types.OktaCredPurposeLabel: types.OktaCredPurposeAuth,
},
},
},
Spec: &types.PluginStaticCredentialsSpecV1{
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
APIToken: oktaSettings.apiToken,
},
},
},
}

if oktaSettings.scimToken != "" {
scimTokenHash, err := bcrypt.GenerateFromPassword([]byte(oktaSettings.scimToken), bcrypt.DefaultCost)
if err != nil {
return trace.Wrap(err)
}

creds = append(creds, &types.PluginStaticCredentialsV1{
ResourceHeader: types.ResourceHeader{
Metadata: types.Metadata{
Name: p.install.name + "-scim-token",
Labels: map[string]string{
types.OktaCredPurposeLabel: types.OktaCredPurposeSCIMToken,
},
},
},
Spec: &types.PluginStaticCredentialsSpecV1{
Credentials: &types.PluginStaticCredentialsSpecV1_APIToken{
APIToken: string(scimTokenHash),
},
},
})
}

req := &pluginsv1.CreatePluginRequest{
Plugin: &types.PluginV1{
SubKind: types.PluginSubkindAccess,
Metadata: types.Metadata{
Labels: map[string]string{
types.HostedPluginLabel: "true",
},
Name: p.install.name,
},
Spec: types.PluginSpecV1{
Settings: &types.PluginSpecV1_Okta{
Okta: &types.PluginOktaSettings{
OrgUrl: oktaSettings.org.String(),
SyncSettings: &types.PluginOktaSyncSettings{
SsoConnectorId: oktaSettings.samlConnector,
AppId: oktaSettings.appID,
SyncUsers: oktaSettings.userSync,
SyncAccessLists: oktaSettings.accessListSync,
DefaultOwners: oktaSettings.defaultOwners,
GroupFilters: oktaSettings.groupFilters,
AppFilters: oktaSettings.appFilters,
},
},
},
},
},
StaticCredentialsList: creds,
CredentialLabels: map[string]string{
types.OktaOrgURLLabel: oktaSettings.org.String(),
},
}

if _, err := args.plugins.CreatePlugin(ctx, req); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web UI doesn't let you create more than 1 instance of a hosted plugin, incl. Okta. How will this behave if you already have an Okta integration running?

And similarly, our UI prompts user to cleanup old Okta integration before creating a new one if it detects there are resources left from the previous instance of Okta integration. Does this behave the same way? @smallinsky should have details about it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes It should behave the same way and the cleanup step will be retuned in error message.

log.ErrorContext(ctx, "Plugin creation failed", logErrorMessage(err))
return trace.Wrap(err)
}

fmt.Println("See https://goteleport.com/docs/application-access/okta/hosted-guide for help configuring provisioning in Okta")
return nil
tcsc marked this conversation as resolved.
Show resolved Hide resolved
}

// InstallSCIM implements `tctl plugins install scim`, installing a SCIM integration
// plugin into the teleport cluster
func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Client) error {
Expand All @@ -197,8 +402,7 @@ func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Cli
log.DebugContext(ctx, "Fetching cluster info...")
info, err := client.Ping(ctx)
if err != nil {
log.ErrorContext(ctx, "Failed fetching cluster info", logErrorMessage(err))
return trace.Wrap(err)
return trace.Wrap(err, "failed fetching cluster info")
}

scimBaseURL := fmt.Sprintf("https://%s/v1/webapi/scim/%s", info.ProxyPublicAddr, p.install.name)
Expand All @@ -212,21 +416,16 @@ func (p *PluginsCommand) InstallSCIM(ctx context.Context, client *authclient.Cli
log.DebugContext(ctx, "Validating SAML Connector...", logFieldSAMLConnector, connectorID)
connector, err := client.GetSAMLConnector(ctx, p.install.scim.samlConnector, false)
if err != nil {
log.ErrorContext(ctx, "Failed validating SAML connector",
slog.String(logFieldSAMLConnector, connectorID),
logErrorMessage(err))

if !p.install.scim.force {
return trace.Wrap(err)
return trace.Wrap(err, "failed validating SAML connector")
}
}

role := p.install.scim.role
log.DebugContext(ctx, "Validating Default Role...", logFieldRole, role)
if _, err := client.GetRole(ctx, role); err != nil {
log.ErrorContext(ctx, "Failed validating role", slog.String(logFieldRole, role), logErrorMessage(err))
if !p.install.scim.force {
return trace.Wrap(err)
return trace.Wrap(err, "failed validating role")
}
}

Expand Down Expand Up @@ -288,6 +487,9 @@ func (p *PluginsCommand) TryRun(ctx context.Context, cmd string, client *authcli
switch cmd {
case p.cleanupCmd.FullCommand():
err = p.Cleanup(ctx, client)
case p.install.okta.cmd.FullCommand():
args := installPluginArgs{samlConnectors: client, plugins: client.PluginsClient()}
err = p.InstallOkta(ctx, args)
case p.install.scim.cmd.FullCommand():
err = p.InstallSCIM(ctx, client)
case p.delete.cmd.FullCommand():
Expand Down
Loading
Loading