Skip to content

Gate external-principal indexing behind an opt-in sync option - #1094

Draft
johnallers wants to merge 4 commits into
mainfrom
johnallers/gate-external-principal-indexing
Draft

Gate external-principal indexing behind an opt-in sync option#1094
johnallers wants to merge 4 commits into
mainfrom
johnallers/gate-external-principal-indexing

Conversation

@johnallers

@johnallers johnallers commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

Makes the indexed external-principal matcher added in #1046 opt-in, and restores the pre-#1046 linear scan as the default.

New sync option:

// Defaults to false (linear scan).
sync.WithExternalPrincipalIndex(enabled bool) SyncOpt

Threaded through the layers standalone connector binaries go through, so they get the same switch for free:

  • local.WithExternalPrincipalIndex(bool) Option (pkg/tasks/local) — one-shot / on-demand
  • c1api.NewC1TaskManagernewFullSyncTaskHandlerfullSyncTaskHandler.sync — service / daemon mode
  • connectorrunner.WithExternalPrincipalIndex(bool) Option — feeds both of the above
  • --external-principal-index / BATON_EXTERNAL_PRINCIPAL_INDEX (field.ExternalPrincipalIndexField, default false)

All four places in the tree that assemble sdkSync.SyncOpts carry the flag.

Why

#1046 is a real fix: the old matcher was O(grants × principals) proto unmarshals and a tenant with ~99k externally matched grants never finished the step inside the sync deadline. Nothing here questions that, and nothing in pkg/sync/external_principal_index.go is modified.

The concern is blast radius, not correctness. As merged, the new grant-matching path becomes mandatory for every tenant the moment its connector bumps its baton-sdk dependency — baton-sharepoint, baton-aks, baton-eks and baton-google-cloud-platform all use this feature today. A behavior change in grant matching is an access-correctness change, so it wants a per-tenant rollout and a rollback that doesn't require an SDK downgrade and a connector re-release.

So: default to the behavior every tenant is running today, let the platform enable the index per tenant, and remove the flag once it's at 100%. This is a kill switch, not a revert.

What changed

Only the "match by key/val" branch of processGrantsWithExternalPrincipals is forked, into two methods:

Everything else is shared and untouched: setup, ExternalResourceMatchAll, ExternalResourceMatchID, the expandable-entitlement map, the delete/put loop. #1046's observability additions (progress log interval, the info/debug lines) are orthogonal and stay active in both modes, with an added indexed field so logs say which matcher ran.

The per-trait index is only built when the indexed matcher is selected — indexing every principal is exactly the work the legacy path exists to avoid.

One deliberate deviation from a verbatim restore: the legacy path's "error getting user trait" log records the principal's ids rather than the whole resource. Logging the resource serializes its profile, which is where a directory keeps emails and employee numbers; #1046 fixed that and re-introducing it as part of a rollback switch would be a privacy regression. It has no effect on which grants are produced.

Tested

  • TestExternalPrincipalMatchLegacyIndexedParity (new) — the actual proof the flag is safe to flip. One fixture through processGrantsWithExternalPrincipals twice, once per mode, requiring byte-identical store contents (deterministic proto marshal of every resulting grant). Fixture covers: a user matched on a non-email profile key (case-insensitively), a user matched via user-trait email, a user matched both ways at once (must yield one grant, not two), a user whose user trait can't be unmarshalled (must be skipped outright, including for the profile key it would otherwise match), group principals matched on a profile key — one with a resolvable expandable entitlement and one hitting the not-found arm — a match key no principal carries, a match against an unconfigured trait, and MatchAll / MatchByID carriers that the flag does not fork. Run against both the SQLite and Pebble engines, and with both the default trait set and a TRAIT_GROUP-only set (which exercises the "TRAIT_USER match arrives but USER isn't a configured match trait" arm on both sides).
  • TestExternalPrincipalMatchParityFixtureExpectations (new) — pins what the two matchers agree on, so parity can't be satisfied by two identically-wrong matchers.
  • TestExternalPrincipalMatchParitySkipsUnreadableUserTrait (new) — the skip is an absence, which a digest comparison alone wouldn't catch.
  • TestExternalResourceUserProfileMatch — added WithExternalPrincipalIndex(true); it was written for the indexed path, which is no longer the default.
  • TestExternalPrincipalMatchDedupesAtTheMatcher (new) — asserts on the grants the matchers return, not on store contents: two grants for one principal share a generated grant id and get upserted into a single row, so a lost dedup is invisible to any post-PutGrants assertion.
  • TestExternalResourceMatch*FoldingContract / TestExternalResourceMatchEmitsOneGrantPerPrincipal — now run in both modes (see review fixes below).
  • TestWithExternalPrincipalIndexOptionWiring (new) — pins the default and the opt-in through NewSyncer.
  • Existing external_principal_index_test.go and the verification-tagged suite are unchanged and still pass.
go build ./...
go test ./pkg/sync/... ./pkg/connectorrunner/... ./pkg/cli/... ./pkg/field/... ./pkg/tasks/... -count=1     # all ok
go test -tags verification ./pkg/sync/ -run TestVerificationExternalPrincipal -count=1                      # ok
golangci-lint run ./pkg/sync/... ./pkg/cli/... ./pkg/field/... ./pkg/connectorrunner/... ./pkg/tasks/...    # only a pre-existing nolintlint hit in ingest_invariants.go, untouched here

Breaking change + release note

c1api.NewC1TaskManager is exported and this PR inserts a positional bool (externalPrincipalIndex) mid-argument-list, so any out-of-repo caller will fail to compile. Together with the default behavior flip (indexed → linear scan for existing callers), the release this lands in should be a minor bump (v0.25.0), not a patch.

pkg/sdk/version.go is deliberately not touched here: version bumps in this repo are made by release automation in standalone commits (c15e57f, d246787, …), so hand-editing it in a feature PR would fight that process. Flagging it for whoever cuts the release instead. Happy to include the bump in this PR if that's the preference.

Reshaping NewC1TaskManager into a variadic-option or config-struct constructor is the right long-term fix for this argument list (14 positional parameters, five of them now sync configuration). That is deliberately deferred — it is a much larger, riskier change than a kill-switch PR should carry, and it would break the same callers a second time.

Review fixes (second commit)

An adversarial review found the equivalence itself clean under mutation testing, plus three gaps, all fixed in e68cee0:

  1. The flag never reached service mode. runner.go threaded it into local.NewSyncer only, so a daemon-mode connector (c1api.NewC1TaskManager) setting BATON_EXTERNAL_PRINCIPAL_INDEX=true silently kept using the linear scan — the exact deployment mode [CXP-498] Index external principals for grant matching #1046's motivating incident happened in, which made the switch useless where it matters most. Now plumbed hop-for-hop alongside externalResourceTraits.
  2. external_match_folding_test.go silently lost indexed coverage. It builds a bare &syncer{}, so defaulting the flag to false moved the folding contracts (added in [CXP-498] Pin external-resource match folding as a behavioral contract #1060) off the matcher they were written to pin. The linear scan satisfies them for free by calling strings.EqualFold directly, leaving the index's bucketing — where a fold can actually be gotten wrong — unpinned. All three tests now run in both modes. Verified by mutation: bucketing on strings.ToLower fails greek-final-sigma, long-s and dotted-capital-İ in indexed=true only.
  3. The parity digest could not see a lost dedup (upserted away by PutGrants before the comparison). Closed with the matcher-level assertion above. Verified by mutation: deleting the email-match continue in the legacy scan now fails the suite.

Review fixes (third commit)

Automated review found two further gaps, fixed in af9a848:

  1. The service-mode fix had no regression test — deleting any of its four plumbing lines left the suite green, which is precisely how the path came to be dropped in the first place. The two hand-offs that carry configuration through positional argument lists are now methods (c1ApiTaskManager.newFullSyncHandler, fullSyncTaskHandler.syncOpts, both pure extractions), and pkg/tasks/c1api/full_sync_opts_test.go asserts them by running the assembled options through a real sync engine and reading the field they set. externalResourceTraits is covered the same way, since it had the identical gap. Verified by mutation: dropping the append in syncOpts, hardcoding the handler field, and passing false from the manager each fail the suite.

    Note on technique: the assertion reads unexported syncer fields reflectively. A SyncOpt is an opaque closure that cannot be identified or applied from outside pkg/sync, so letting the options act on a real engine is the only way to prove one survived the plumbing rather than merely that some option was appended. (Comparing option code pointers does not work — reflect yields a closure instance address, so two calls of the same constructor already differ.) Three of the four hops are now mutation-covered; connectorrunnerNewC1TaskManager still needs a live service client to reach and remains uncovered.

  2. The verification-tagged crash-cut / resume / same-checkpoint-twice cell built bare syncers, so after the default flip it only replayed the legacy scan — the indexed path was never resumed or replayed by any test. Now parameterized over both matchers with the same helper used for the folding contracts. Both modes agree on every batch size the cell pins ({9}, {33}, {33, 27}), which is itself a small extra equivalence signal across the checkpoint seam.

One inline bot comment about the service-mode branch not receiving the flag was posted against the pre-fix commit 79ecb38 and is stale; the reviewer's own summary at e68cee0 confirms it.

Review fixes (fourth commit)

Two more from automated review, fixed in 1faf55b:

  1. The local / CLI chain had no coverage — round 3 proved out the service-mode chain and left its twin untested, even though --external-principal-indexconnectorrunner.WithExternalPrincipalIndexcfglocal.WithExternalPrincipalIndexsdkSync.WithExternalPrincipalIndex → engine has exactly the same drop-a-value-silently shape. localSyncer.syncOpts is now split out of Process (same pure extraction as fullSyncTaskHandler.syncOpts) and asserted the same way, plus a runner-level test that the Option reaches runnerConfig. Mutation-verified: dropping the option from localSyncer.syncOpts, dropping the value in local.WithExternalPrincipalIndex, and dropping it in connectorrunner.WithExternalPrincipalIndex each fail.

  2. Misplaced doc comment in full_sync_opts_test.go — the externalResourceTraits comment sat above the manager hand-off test. Moved.

Wiring coverage after all rounds

Hop Covered
SyncOptsyncer.externalPrincipalIndexEnabled pkg/syncTestWithExternalPrincipalIndexOptionWiring
connectorrunner.OptionrunnerConfig pkg/connectorrunnerTestWithExternalPrincipalIndexOption
localSyncer → engine pkg/tasks/localTestLocalSyncerThreadsExternalPrincipalIndex
c1ApiTaskManager → handler pkg/tasks/c1apiTestC1TaskManagerThreadsExternalPrincipalIndexToHandler
handler → engine pkg/tasks/c1apiTestFullSyncTaskHandlerThreadsExternalPrincipalIndex
NewConnectorRunner → task manager not covered — needs a live connector wrapper to construct

Every row except the last is mutation-verified.

Coordination note

ben.su/ce-975/external-resource-traits (referenced from #1046's description) touches the same function. This PR restructures that region into matchExternalPrincipalsLegacy / matchExternalPrincipalsIndexed, so whichever lands second will need a rebase. Not resolved here — flagging for whoever sequences the two. The new trait plumbing should slot into both matchers identically (both already take matchTraits), but that wants a look from someone with that branch's context.

Open question

The CLI flag is registered visible rather than WithHidden(true) (which is what --skip-grants does). It's an internal engine toggle, but it's also the only escape hatch a standalone connector operator has on a large tenant. Happy to hide it if the preference is to keep the flag surface minimal.

PR #1046 replaced the per-grant linear scan of external principals in
processGrantsWithExternalPrincipals with an indexed lookup. The scan was
O(grants x principals) and did not finish at all on a tenant with ~99k
externally matched grants, so the index is the right fix -- but it is new
code in security-relevant grant-matching logic that would otherwise become
mandatory for every live tenant the moment its connector bumps this SDK.

Make the indexed path opt-in via WithExternalPrincipalIndex(bool),
defaulting to the pre-#1046 linear scan, so the fix can be rolled out per
tenant behind a downstream feature flag and rolled back without an SDK
downgrade. This is a kill switch, not a revert: the index and its tests are
untouched, and the flag is expected to reach 100% and then be removed.

Only the "match by key/val" branch is forked, into
matchExternalPrincipalsLegacy and matchExternalPrincipalsIndexed. MatchAll,
MatchByID, the delete/put loop, the ctx checks and #1046's progress logging
are shared and unchanged, and the index is not built at all when the legacy
matcher is selected.

Equivalence between the two paths is the entire premise of the flag, so it
is asserted directly: TestExternalPrincipalMatchLegacyIndexedParity runs one
fixture -- users matched by profile key, by user-trait email, by both at
once, a principal with an unreadable user trait, group profile matches with
and without a remappable expandable entitlement, an unmatched key, an
unconfigured trait, plus MatchAll/MatchByID carriers -- through both
matchers on both storage engines and requires byte-identical grants.

The option is threaded through tasks/local, connectorrunner and the CLI
(--external-principal-index / BATON_EXTERNAL_PRINCIPAL_INDEX) so connector
binaries built on the SDK get the same switch.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
local.WithTargetedSyncResources(resources),
local.WithSkipEntitlementsAndGrants(cfg.skipEntitlementsAndGrants),
local.WithSkipGrants(cfg.skipGrants),
local.WithExternalPrincipalIndex(cfg.externalPrincipalIndex),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Bug: cfg.externalPrincipalIndex is only consumed here, on the one-shot/local.NewSyncer branch. The service-mode branch below (c1api.NewC1TaskManager, runner.go:1150) never receives it, and pkg/tasks/c1api/full_sync.go never appends sdkSync.WithExternalPrincipalIndex, so --external-principal-index / BATON_EXTERNAL_PRINCIPAL_INDEX is a silent no-op for platform-run syncs — no error, no warning, and the indexed=false log line is the only clue.

That is the deployment mode the index was built for: the ~99k-grant × ~93k-principal tenant from #1046 that blew the sync deadline runs under the platform, not as a one-shot CLI invocation. As written this PR makes the O(grants × principals) scan the default everywhere and leaves the platform with no way to opt a tenant back onto the fast path, so the #1046 fix becomes unreachable where it mattered — which is the opposite of the PR's stated "let the platform enable the index per tenant" rollout plan.

Either thread the flag into NewC1TaskManager/full_sync.go (as keepPreviousSyncC1Z is threaded), or land the platform-side toggle first and say so in the PR.

func parityPrincipals(t *testing.T) []*v2.Resource {
t.Helper()
return []*v2.Resource{
// Matches "upn"/target@example.com, differing in case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: every value in this fixture is ASCII, so parity is only ever asserted over inputs where foldKey bucketing and strings.EqualFold trivially agree. The interesting divergences the index was designed around — final/medial sigma, long s, dotted capital I — are exactly where the two matchers could disagree, and they're never run through this comparison. external_match_folding_test.go used to cover them at the processGrantsWithExternalPrincipals seam, but it builds &syncer{store, state} without the flag, so post-PR it only exercises the legacy scan.

Adding a couple of the non-ASCII cases from externalMatchFoldCases() to parityPrincipals/parityCarrierGrants would make this test the actual proof it claims to be.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Gate external-principal indexing behind an opt-in sync option

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f7333f66e01d.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. Both prior findings are addressed: the externalResourceTraits doc comment now sits above TestFullSyncTaskHandlerThreadsExternalResourceTraits, and the local/CLI chain gained localSyncer.syncOpts plus TestLocalSyncerThreadsExternalPrincipalIndex and TestWithExternalPrincipalIndexOption. I diffed matchExternalPrincipalsLegacy against the pre-#1046 source at abc69f3b and it is a faithful restore, the only deviation being the documented identifiers-instead-of-resource log change. I also checked that the two matchers agree on skip-on-unreadable-user-trait, one-grant-per-principal, principal ordering, the TRAIT_USER-not-a-configured-trait arm, and the shared MatchAll / MatchID / expandable paths. The default flip is not a regression for anyone: #1046 merged 2026-08-13, after the v0.24.1 release, so no tenant is running the indexed matcher today. No blocking issues; two test and robustness suggestions below.

Risk triage (per docs/BUG_CATCHING.md section 2): silent - yes, a matcher divergence yields well-formed but wrong grants with no error; durable - yes, grants land in the c1z and are read by the platform; uncontrolled dimensions - data volume only, with no serialized-state or wire-format change and no version-pair dependence; consumer distance - platform and downstream connectors; consequence - re-sync (rung 2), access-correctness. Verdict: HIGH (silent plus durable). The PR already carries the instruments that class needs: a differential oracle (TestExternalPrincipalMatchLegacyIndexedParity, byte-identical deterministic proto digest, both engines, both trait sets), an expectation-pinning fixture so parity cannot be satisfied by two identically-wrong matchers, an absence assertion for the unreadable-user-trait skip, a matcher-level dedup assertion that survives the PutGrants upsert, the crash-cut / resume / same-checkpoint-twice verification cell in both modes, and mutation-verified wiring tests on four of five plumbing hops. The NewConnectorRunner to NewC1TaskManager hop remains uncovered, but the intervening parameter types make a silent misordering non-compiling, so I did not raise it as a finding.

c1api.NewC1TaskManager is exported and gains a positional bool, which breaks out-of-repo callers. The PR documents this and asks for a minor bump. Leaving pkg/sdk/version.go untouched is correct here: every bump in this repo lands as a standalone release commit (c15e57f, d246787, 897f9ef, ...), so this is a release-cut item rather than a diff item.

Security Issues

None found. The restored legacy log path records only principal_resource_type_id and principal_resource_id rather than the whole resource, so the profile-serialization fix from #1046 stays intact on the default path.

Correctness Issues

None found.

Suggestions

  • pkg/sync/syncer.go:3513 - the matchTraits[trait] arm of matchExternalPrincipalsIndexed dereferences indexByTrait[trait] with no nil check, and the invariant justifying that now lives in a different function than the deref.
  • pkg/sync/syncer_test.go:1338 - pinning TestExternalResourceUserProfileMatch to WithExternalPrincipalIndex(true) moves the only end-to-end user profile-key match off the shipped default, though nothing in it is indexed-specific.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/sync/syncer.go`:
- Around line 3501-3513: In matchExternalPrincipalsIndexed, the "case matchTraits[trait]:" arm does
  "idx := indexByTrait[trait]" and immediately calls idx.matchProfile(...) with no nil check, while
  the "trait == v2.ResourceType_TRAIT_USER" arm directly above guards with an early break when idx
  is nil. The invariant that makes the unguarded deref safe -- indexByTrait is populated for every
  key of matchTraits -- is now established in processGrantsWithExternalPrincipals, a different
  function from the dereference, because this PR extracted the matcher into its own method taking
  both maps as independent parameters. Add the same nil guard to this arm so a nil index matches
  nothing instead of panicking, and update the existing explanatory comment to describe the guard
  rather than its absence.

In `pkg/sync/syncer_test.go`:
- Around line 1334-1339: TestExternalResourceUserProfileMatch now hardcodes
  WithExternalPrincipalIndex(true) in internalOpts. The test is a TRAIT_USER match on the profile
  key "userPrincipalName", which the legacy matcher resolves through the same
  resource.GetProfileStringValue plus strings.EqualFold comparison, so it is not indexed-specific
  and should pass in both modes. As written it removes the default (linear scan) path from the only
  end-to-end Sync() test of a user profile-key match against a real external c1z. Parameterize the
  test over both matchers instead of pinning it to the indexed one -- the same treatment
  external_match_folding_test.go and external_principal_index_verification_test.go received in this
  PR: a bool selecting whether WithExternalPrincipalIndex(true) is appended, run under a subtest per
  mode. Update the accompanying comment accordingly.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking issues found — see review comments.

Three gaps found in review of the kill-switch change:

1. The option never reached service mode. runner.go threaded
   externalPrincipalIndex into local.NewSyncer only, so a daemon-mode
   connector (c1api.NewC1TaskManager) setting BATON_EXTERNAL_PRINCIPAL_INDEX
   kept silently running the linear scan -- the exact deployment mode the
   incident behind #1046 happened in, which made the flag useless where it
   matters most. Plumbed through NewC1TaskManager, c1ApiTaskManager,
   newFullSyncTaskHandler and fullSyncTaskHandler.sync, mirroring
   externalResourceTraits hop for hop. All four SyncOpt assembly sites in the
   tree now carry it.

2. external_match_folding_test.go built a bare &syncer{}, so defaulting the
   flag to false silently moved TestExternalResourceMatch*FoldingContract off
   the indexed matcher they were written to pin. The linear scan satisfies
   those cases for free by calling strings.EqualFold directly, so the index's
   bucketing -- where a fold can actually be gotten wrong -- was left
   unpinned. Both contracts and the one-grant-per-principal test now run in
   both modes. Verified by mutation: bucketing on strings.ToLower fails
   greek-final-sigma, long-s and dotted-capital-I in indexed=true only.

3. The parity suite could not see a lost dedup, because two grants for one
   principal share a generated grant id and PutGrants upserts them into one
   row before the digest is read. Added a test asserting on the grants the
   matchers RETURN. Verified by mutation: dropping the email-match continue
   in the legacy scan now fails.

Also added a test pinning the option wiring itself: NewSyncer with no options
is the linear scan, WithExternalPrincipalIndex(true) is the indexed path.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@@ -207,6 +211,10 @@ func (c *fullSyncTaskHandler) sync(ctx context.Context, c1zPath string) error {
syncOpts = append(syncOpts, sdkSync.WithExternalResourceTraits(c.externalResourceTraits...))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: this hop is the defect the commit fixes, and nothing asserts it. TestWithExternalPrincipalIndexOptionWiring pins only SyncOptsyncer.externalPrincipalIndexEnabled, which was never the broken hop; the silent no-op lived in runner.goNewC1TaskManagernewFullSyncTaskHandler → this syncOpts assembly, and deleting any of those four lines still leaves the whole suite green. Consider a test that builds a fullSyncTaskHandler{externalPrincipalIndex: true} and asserts the assembled syncOpts apply to a *syncer with the field set — the same shape would also cover externalResourceTraits, which has no threading test either.

externalC1Z string,
externalResourceEntitlementIdFilter string,
externalResourceTraits []v2.ResourceType_Trait,
externalPrincipalIndex bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: NewC1TaskManager is exported, so inserting a positional bool in the middle of its parameter list is a compile break for any out-of-repo caller, and it makes the 16-argument list one bool harder to call correctly (skipFullSync and keepPreviousSyncC1Z are already positional bools). The break is loud rather than silent, and it matches how externalResourceTraits was threaded, so this isn't blocking — but per the repo's SDK-compatibility criteria it's worth either moving this constructor to a variadic-option/config-struct shape or calling the signature change out alongside a pkg/sdk/version.go bump (still v0.24.1 on this branch) so downstreams get a version signal for it plus the default-behavior flip.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

1. The service-mode fix had no regression test: deleting any of its four
   plumbing lines left the suite green, which is how the path got dropped in
   the first place. Split the two hand-offs that carry configuration through
   positional argument lists into methods -- c1ApiTaskManager.newFullSyncHandler
   and fullSyncTaskHandler.syncOpts -- and asserted them by running the
   assembled options through a real sync engine and reading the field they set.

   The options are opaque closures and the fields they set are unexported, so
   the assertion reads them reflectively; a SyncOpt cannot be identified or
   applied from outside pkg/sync, making this the only way to prove an option
   survived the plumbing rather than merely that some option was appended.
   (Comparing option code pointers does not work: reflect gives a closure
   instance address, so two calls of one constructor already differ.)

   Verified by mutation: dropping the append in syncOpts, hardcoding the
   handler field to false, and passing false from the manager each fail.
   Covers three of the four hops; connectorrunner -> NewC1TaskManager still
   needs a live service client to reach, and is noted on the PR.

2. The verification-tagged crash-cut / resume / same-checkpoint-twice cell
   built bare syncers, so post-default-flip it only replayed the legacy scan --
   the indexed path, the one being rolled out, was never resumed by any test.
   Parameterized over both matchers with the existing helper. Both modes agree
   on every batch size the cell pins.

pkg/sdk/version.go is deliberately untouched: bumps in this repo are made by
release automation in standalone commits. The release this lands in needs a
minor bump rather than a patch -- exported signature break plus a default
behavior change -- which is called out in the PR description instead.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/tasks/c1api/full_sync_opts_test.go Outdated
Comment on lines +140 to +145
// externalResourceTraits reaches the engine through the same chain and had the
// same untested gap; it is cheap to cover here alongside.
// The manager -> handler hop is the other half of the chain, and the one that
// silently dropped the value the first time. It is asserted here rather than
// through Process(), which needs a live task queue and service client.
func TestC1TaskManagerThreadsExternalPrincipalIndexToHandler(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: the first two lines of this comment describe externalResourceTraits, but they sit on TestC1TaskManagerThreadsExternalPrincipalIndexToHandler, which never touches traits. They belong on TestFullSyncTaskHandlerThreadsExternalResourceTraits (line 159), which is currently undocumented. Confidence: high (cosmetic).

Suggested change
// externalResourceTraits reaches the engine through the same chain and had the
// same untested gap; it is cheap to cover here alongside.
// The manager -> handler hop is the other half of the chain, and the one that
// silently dropped the value the first time. It is asserted here rather than
// through Process(), which needs a live task queue and service client.
func TestC1TaskManagerThreadsExternalPrincipalIndexToHandler(t *testing.T) {
// The manager -> handler hop is the other half of the chain, and the one that
// silently dropped the value the first time. It is asserted here rather than
// through Process(), which needs a live task queue and service client.
func TestC1TaskManagerThreadsExternalPrincipalIndexToHandler(t *testing.T) {

local.WithTargetedSyncResources(resources),
local.WithSkipEntitlementsAndGrants(cfg.skipEntitlementsAndGrants),
local.WithSkipGrants(cfg.skipGrants),
local.WithExternalPrincipalIndex(cfg.externalPrincipalIndex),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: the new commit closes the service-mode plumbing gap (c1ApiTaskManager → handler → syncOpts → engine), but the local path that the new --external-principal-index CLI flag actually drives is still untested end to end: cliconnectorrunner.WithExternalPrincipalIndexcfg.externalPrincipalIndex → this local.WithExternalPrincipalIndexsdkSync.WithExternalPrincipalIndex. pkg/tasks/local has no syncer test and runner_test.go has no option-wiring test, so a value dropped on this branch would silently sync with the linear scan while the flag reads as on — the same silent-misconfiguration failure the c1api tests were added for. A small test asserting localSyncer.externalPrincipalIndex reaches the engine would cover it. Confidence: high (test gap, not a defect).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

The local task path carries configuration to the sync engine through the same
shape service mode does -- CLI flag, runner Option, task field, SyncOpt -- and
had none of the coverage service mode just gained. Split localSyncer.syncOpts
out of Process the way fullSyncTaskHandler.syncOpts was split, and asserted the
chain by running the assembled options through a real engine and reading the
field they set. Added the runner half too: WithExternalPrincipalIndex must
reach runnerConfig.

Verified by mutation: dropping the option from localSyncer.syncOpts, dropping
the value in local.WithExternalPrincipalIndex, and dropping it in
connectorrunner.WithExternalPrincipalIndex each fail. The one hop still not
covered either way is NewConnectorRunner handing cfg to a task manager, which
needs a live connector wrapper to construct.

Also moved a doc comment in full_sync_opts_test.go that described the
externalResourceTraits test but sat above the manager hand-off test.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/sync/syncer.go
// matchTraits. The guard in the TRAIT_USER case above is not
// the same situation -- that case fires on the trait named by
// the grant, which need not be a configured match trait.
idx := indexByTrait[trait]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: The TRAIT_USER arm above nil-checks idx, but this arm does not, and the invariant that justifies it (indexByTrait is populated for every key of matchTraits) now lives in a different function than the deref. Before this PR both maps were locals in one function body; now they are independent parameters, so any future caller that builds them asymmetrically panics here rather than matching nothing. The new TestExternalPrincipalMatchDedupesAtTheMatcher already constructs exactly that pair (matchTraits has USER+GROUP, indexByTrait has only USER) — it just never passes a GROUP match. A three-line if idx == nil { break } would make this arm self-contained the way the one above it is.

Comment thread pkg/sync/syncer_test.go
WithC1ZPath(internalC1zpath),
WithTmpDir(tempDir),
WithExternalResourceC1ZPath(externalC1zpath),
WithExternalPrincipalIndex(true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Nothing in this test is actually indexed-specific — it is a TRAIT_USER profile-key match on userPrincipalName, which the legacy arm handles through the same GetProfileStringValue + strings.EqualFold comparison, so it should pass in both modes. Pinning it to WithExternalPrincipalIndex(true) therefore moves the only end-to-end Sync() coverage of a user profile-key match (through a real external-c1z reader, under runWithSyncModes) off the path this PR ships as the default. The parity fixture asserts the same shape, but at the processGrantsWithExternalPrincipals level rather than end-to-end. Consider running it in both modes instead, the way external_match_folding_test.go and the verification cell were parameterized in this same PR.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant