From 12e3b692ddbb6a5436e7a8714b72883c9ac8b159 Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Wed, 26 Aug 2026 15:11:09 -0700 Subject: [PATCH] Make syncing secrets an opt in resource type. Deprecate the sync secrets config option. This change is backwards compatible. Existing connectors with sync-secrets enabled will still sync API tokens. This fixes a bug in incremental sync when sync secrets is disabled, but changes to api tokens still show up in the event feed. --- README.md | 4 +- baton_capabilities.json | 6 ++- config_schema.json | 2 +- docs/connector.mdx | 4 +- docs/docs-info.md | 4 +- pkg/config/config.go | 2 +- pkg/connector/api_token.go | 20 +++++++--- pkg/connector/api_token_test.go | 65 +++++++++++++++++++++++++++++++++ pkg/connector/connector.go | 41 ++++++++------------- 9 files changed, 107 insertions(+), 41 deletions(-) create mode 100644 pkg/connector/api_token_test.go diff --git a/README.md b/README.md index d36988608..de2ef9dd4 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ For syncing custom roles `--sync-custom-roles` must be provided. Its default val Devices are an opt-in resource type: enable the **Device** resource type in the connector's sync configuration to include it. Device sync is read-only; the connector does not manage device-to-user assignments. -Secrets (Okta API tokens) sync only when `--sync-secrets` is provided. Its default value is `false`. They are read-only, and C1 displays them on the Inventory page. +Secrets (Okta API tokens) are an opt-in resource type: enable the **API Token** resource type in the connector's sync configuration to include them. `--sync-secrets` is deprecated but still honored. They are read-only, and C1 displays them on the Inventory page. The connector also supports an event feed and targeted sync. The event feed streams a filtered set of Okta System Log events: user lifecycle and profile changes, application creation and update, group creation, group memberships added and removed, application assignment and admin-role grants, and SSO sign-ins. Targeted sync narrows a run to specific resource types or resource IDs via `--sync-resource-types` and `--sync-resources`. @@ -231,7 +231,7 @@ Flags: --sync-inactive-apps Whether to sync inactive apps or not ($BATON_SYNC_INACTIVE_APPS) (default true) --sync-resource-types strings The resource type IDs to sync ($BATON_SYNC_RESOURCE_TYPES) --sync-resources strings The resource IDs to sync ($BATON_SYNC_RESOURCES) - --sync-secrets Whether to sync secrets or not ($BATON_SYNC_SECRETS) + --sync-secrets Deprecated: enable the API Token resource type in the connector sync configuration instead. When true, API tokens are still synced. ($BATON_SYNC_SECRETS) --task-concurrency int The number of Baton tasks to run concurrently in service mode. Tasks may include sync, grant, revoke, and more. Minimum value is 1, maximum value is 100. ($BATON_TASK_CONCURRENCY) (default 3) --ticketing This must be set to enable ticketing support ($BATON_TICKETING) -v, --version version for baton-okta diff --git a/baton_capabilities.json b/baton_capabilities.json index 14046e68a..70346e3b3 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -23,6 +23,9 @@ "permission": "okta.apiTokens.read" } ] + }, + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" } ] }, @@ -36,7 +39,8 @@ "permission": "okta.apiTokens.read" } ] - } + }, + "optInRequired": true }, { "resourceType": { diff --git a/config_schema.json b/config_schema.json index af07e9848..b8ccca23b 100644 --- a/config_schema.json +++ b/config_schema.json @@ -174,7 +174,7 @@ { "name": "sync-secrets", "displayName": "Sync secrets", - "description": "Whether to sync secrets or not", + "description": "Deprecated: enable the API Token resource type in the connector sync configuration instead. When true, API tokens are still synced.", "boolField": {} }, { diff --git a/docs/connector.mdx b/docs/connector.mdx index a5278bbd4..d2d2b6f73 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -343,7 +343,7 @@ Navigate to the **Okta API Scopes** tab and grant the scopes required for your u - `okta.users.read` and `okta.groups.read` (required for sync) - `okta.roles.read` and `okta.apps.read` (required for sync) - `okta.users.manage`, `okta.groups.manage`, `okta.roles.manage`, `okta.apps.manage` (required for provisioning) - - `okta.apiTokens.read` (required when **Sync secrets** is enabled) + - `okta.apiTokens.read` (required when the **API Token** resource type is enabled) - `okta.devices.read` (required when the **Device** resource type is enabled) @@ -442,7 +442,7 @@ Enter your credentials: **Optional.** Inactive (disabled) Okta applications are synced by default. Clear the **Sync inactive apps** checkbox to exclude them. -**Optional.** Enable **Sync secrets** to display them on the [Inventory page](/product/admin/inventory). +**Optional.** Enable the **API Token** resource type in the connector's sync configuration to display Okta API tokens on the [Inventory page](/product/admin/inventory). **Optional.** Enter a list of user email domains that will be included in the connector's sync. If you do not specify any domains here, the connector will sync all available accounts regardless of email domain. diff --git a/docs/docs-info.md b/docs/docs-info.md index 2ab739584..ecde0cbcd 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -12,7 +12,7 @@ Internal technical notes for maintainers. Customer-facing setup lives in [`docs/ - **Applications** — Okta apps; optional inactive apps via `--sync-inactive-apps` - **Roles** — Standard admin roles - **Custom roles / resource sets / bindings** — When `--sync-custom-roles` is enabled - - **Secrets (API tokens)** — When `--sync-secrets` is enabled + - **Secrets (API tokens)** — **Opt-in** resource type (`api-token`); `--sync-secrets` is deprecated but still honored - **Devices** — Okta-managed devices; **opt-in** resource type, off unless explicitly selected (see [Devices](#devices)) 2. Can the connector provision any resources? If so, which ones? @@ -83,7 +83,7 @@ Consequences for setup docs: The users case is the dangerous one: the sync exits 0 and writes a bundle containing zero users, so a missing admin role presents as a successful empty sync rather than an error. Assigning Super Administrator makes both return data. -**Requested scopes.** On this path the connector requests the four default read scopes plus all four `*.manage` provisioning scopes unconditionally, then adds `okta.apiTokens.read` when `--sync-secrets` is set and `okta.devices.read` when device sync is enabled. Per the note in `connector.go`, a scope the app has not been granted drops from the issued token and only surfaces as a 403 on first use, so a read-only app still authenticates. +**Requested scopes.** On this path the connector requests the four default read scopes plus all four `*.manage` provisioning scopes unconditionally, then adds `okta.apiTokens.read` when API token sync is enabled (`--sync-secrets` or an explicit `api-token` resource-type filter) and `okta.devices.read` when device sync is enabled. Per the note in `connector.go`, a scope the app has not been granted drops from the issued token and only surfaces as a 403 on first use, so a read-only app still authenticates. **Console caveat for whoever writes customer docs.** During CXH-2092 / CXH-2124 validation, the Okta Admin Console's **Okta API Scopes** tab twice failed to persist scope grants with no error shown — the grants did not appear in `GET /api/v1/apps/{id}/grants`. `POST /api/v1/apps/{id}/grants` worked reliably. The customer-facing walkthrough directs readers to that tab, so it carries a note telling them to refresh and visually confirm the granted scopes. diff --git a/pkg/config/config.go b/pkg/config/config.go index e30fbfe1c..d51f8d72f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -75,7 +75,7 @@ var ( ) syncSecrets = field.BoolField("sync-secrets", field.WithDisplayName("Sync secrets"), - field.WithDescription("Whether to sync secrets or not"), + field.WithDescription("Deprecated: enable the API Token resource type in the connector sync configuration instead. When true, API tokens are still synced."), field.WithDefaultValue(false), ) filterEmailDomains = field.StringSliceField("filter-email-domains", diff --git a/pkg/connector/api_token.go b/pkg/connector/api_token.go index 5164d7c42..7b87f23c2 100644 --- a/pkg/connector/api_token.go +++ b/pkg/connector/api_token.go @@ -15,7 +15,7 @@ import ( type apiTokenResourceType struct { resourceType *v2.ResourceType - clientV5 *oktav5.APIClient + connector *Okta } func (o *apiTokenResourceType) Entitlements(ctx context.Context, resource *v2.Resource, attrs resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) { @@ -37,6 +37,10 @@ func (o *apiTokenResourceType) List( resourceID *v2.ResourceId, attrs resource.SyncOpAttrs, ) ([]*v2.Resource, *resource.SyncOpResults, error) { + if !o.connector.shouldFetchApiTokens() { + return nil, &resource.SyncOpResults{}, nil + } + token := &attrs.PageToken bag, prevSerializedResp, err := parsePageToken(token.Token, &v2.ResourceId{ResourceType: resourceTypeApiToken.Id}) if err != nil { @@ -47,7 +51,7 @@ func (o *apiTokenResourceType) List( var resp *oktav5.APIResponse if prevSerializedResp == "" { - apiTokens, resp, err = o.clientV5.ApiTokenAPI.ListApiTokens(ctx).Execute() + apiTokens, resp, err = o.connector.clientV5.ApiTokenAPI.ListApiTokens(ctx).Execute() if err != nil { return nil, nil, fmt.Errorf("okta-connector-v5: failed to list api tokens: %w", err) } @@ -57,7 +61,7 @@ func (o *apiTokenResourceType) List( return nil, nil, fmt.Errorf("okta-connector-v5: failed to deserialize page token: %w", err) } - localOktaAPIResponse := oktav5.NewAPIResponse(prevResp.Response, o.clientV5, nil) + localOktaAPIResponse := oktav5.NewAPIResponse(prevResp.Response, o.connector.clientV5, nil) if localOktaAPIResponse.HasNextPage() { resp, err = localOktaAPIResponse.Next(&apiTokens) if err != nil { @@ -93,20 +97,24 @@ func (o *apiTokenResourceType) List( return ret, &resource.SyncOpResults{NextPageToken: nextPageToken, Annotations: annos}, nil } -func apiTokenBuilder(clientV5 *oktav5.APIClient) *apiTokenResourceType { +func apiTokenBuilder(connector *Okta) *apiTokenResourceType { return &apiTokenResourceType{ resourceType: resourceTypeApiToken, - clientV5: clientV5, + connector: connector, } } func (o *apiTokenResourceType) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + if !o.connector.shouldFetchApiTokens() { + return nil, nil, nil + } + l := ctxzap.Extract(ctx) l.Debug("getting api token", zap.String("api_token_id", resourceId.Resource)) var annos annotations.Annotations - apiToken, resp, err := o.clientV5.ApiTokenAPI.GetApiToken(ctx, resourceId.Resource).Execute() + apiToken, resp, err := o.connector.clientV5.ApiTokenAPI.GetApiToken(ctx, resourceId.Resource).Execute() if err != nil { return nil, nil, fmt.Errorf("okta-connector-v5: failed to get api token: %w", err) } diff --git a/pkg/connector/api_token_test.go b/pkg/connector/api_token_test.go new file mode 100644 index 000000000..0b97b9f91 --- /dev/null +++ b/pkg/connector/api_token_test.go @@ -0,0 +1,65 @@ +package connector + +import ( + "testing" + + "github.com/conductorone/baton-sdk/pkg/cli" +) + +func TestShouldFetchApiTokens(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + syncSecrets bool + opts *cli.ConnectorOpts + want bool + }{ + { + name: "deprecated flag still fetches", + syncSecrets: true, + opts: &cli.ConnectorOpts{}, + want: true, + }, + { + name: "unfiltered sync does not fetch", + syncSecrets: false, + opts: &cli.ConnectorOpts{}, + want: false, + }, + { + name: "explicit opt-in fetches", + syncSecrets: false, + opts: &cli.ConnectorOpts{SyncResourceTypeIDs: []string{resourceTypeApiToken.Id}}, + want: true, + }, + { + name: "explicit filter without api-token does not fetch", + syncSecrets: false, + opts: &cli.ConnectorOpts{SyncResourceTypeIDs: []string{resourceTypeUser.Id}}, + want: false, + }, + { + name: "flag wins over filter that omits api-token", + syncSecrets: true, + opts: &cli.ConnectorOpts{SyncResourceTypeIDs: []string{resourceTypeUser.Id}}, + want: true, + }, + { + name: "nil opts (capabilities) reports fetch", + syncSecrets: false, + opts: nil, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + o := &Okta{SyncSecrets: tt.syncSecrets, opts: tt.opts} + if got := o.shouldFetchApiTokens(); got != tt.want { + t.Fatalf("shouldFetchApiTokens() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index d32b6de2c..f73306474 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -120,7 +120,7 @@ var ( Id: "api-token", DisplayName: "API Token", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, - Annotations: v1AnnotationsForResourceType("api-token", true, capabilityPermissions("okta.apiTokens.read")), + Annotations: v1AnnotationsForResourceType("api-token", true, capabilityPermissions("okta.apiTokens.read"), &v2.OptInRequired{}), } resourceTypeDevice = &v2.ResourceType{ Id: "device", @@ -152,6 +152,8 @@ func shouldSyncResourceType(opts *cli.ConnectorOpts, resourceTypeID string) bool return opts.WillSyncResourceType(resourceTypeID) } +var _ connectorbuilder.ConnectorBuilderV2 = (*Okta)(nil) + func (o *Okta) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { resourceSyncer := []connectorbuilder.ResourceSyncerV2{ roleBuilder(o.client, o), @@ -176,36 +178,23 @@ func (o *Okta) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceS ) } - if o.SyncSecrets { - resourceSyncer = append(resourceSyncer, apiTokenBuilder(o.clientV5)) - } + resourceSyncer = append(resourceSyncer, apiTokenBuilder(o)) return resourceSyncer } -func (c *Okta) ListResourceTypes(ctx context.Context, request *v2.ResourceTypesServiceListResourceTypesRequest) (*v2.ResourceTypesServiceListResourceTypesResponse, error) { - resourceTypes := []*v2.ResourceType{ - resourceTypeUser, - resourceTypeGroup, - resourceTypeRole, - resourceTypeApp, - } - - if c.SyncCustomRoles { - resourceTypes = append(resourceTypes, resourceTypeCustomRole, resourceTypeResourceSets, resourceTypeResourceSetsBindings) - } - - if c.SyncSecrets { - resourceTypes = append(resourceTypes, resourceTypeApiToken) +// shouldFetchApiTokens reports whether API token resources should be fetched from Okta. +// The api-token type is always advertised (OptInRequired). Fetching happens when the +// deprecated --sync-secrets flag is set, or when the sync filter explicitly includes +// api-token (C1 resource-type opt-in). A full unfiltered CLI sync does not fetch tokens. +func (o *Okta) shouldFetchApiTokens() bool { + if o.SyncSecrets { + return true } - - if shouldSyncResourceType(c.opts, resourceTypeDevice.Id) { - resourceTypes = append(resourceTypes, resourceTypeDevice) + if o.opts == nil { + return true } - - return &v2.ResourceTypesServiceListResourceTypesResponse{ - List: resourceTypes, - }, nil + return o.opts.SyncFilterIsExplicit() && o.opts.WillSyncResourceType(resourceTypeApiToken.Id) } func (c *Okta) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { @@ -450,7 +439,7 @@ func New(ctx context.Context, cc *cfg.Okta, opts *cli.ConnectorOpts) (connectorb case cfg.PrivateKeyGroup: scopes = append(scopes, provisioningScopes...) - if cc.SyncSecrets { + if cc.SyncSecrets || (opts != nil && opts.SyncFilterIsExplicit() && opts.WillSyncResourceType(resourceTypeApiToken.Id)) { scopes = append(scopes, "okta.apiTokens.read") }