Skip to content

[CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier - #55

Open
sergiocorral-conductorone wants to merge 13 commits into
mainfrom
sergiocorral/cxh-2368-baton-tableau-fix-license-grant-rewriting-the-site-role-of-a
Open

sergiocorral-conductorone wants to merge 13 commits into
mainfrom
sergiocorral/cxh-2368-baton-tableau-fix-license-grant-rewriting-the-site-role-of-a

Conversation

@sergiocorral-conductorone

@sergiocorral-conductorone sergiocorral-conductorone commented Oct 16, 2026 •

Copy link
Copy Markdown

INTENT

On baton-tableau v0.1.15, granting a license entitlement to a user who already holds it rewrites their Tableau site role to that tier's base role. A SiteAdministratorCreator user already holding license:creator:member becomes a plain Creator after the grant, and the operation exits 0 with no error. The same shape applies to the Explorer tier, where SiteAdministratorExplorer and ExplorerCanPublish users are rewritten to Explorer.

The license resource type also emits no GrantAlreadyExists or GrantAlreadyRevoked annotations, so a no-op provisioning call is indistinguishable from one that changed state.

Fixes CXH-2368

What / why

licenseBuilder.Grant resolved the entitlement to a literal role and PUT it unconditionally, without reading the user's current site role. Because several site roles map to one tier (RolesPerLicense), granting a tier the user already held overwrote e.g. SiteAdministratorCreator with Creator. Revoke likewise PUT Unlicensed without checking what the user currently holds.

How

  • New Client.GetUser (single-user GET) and client.IsNotFoundError.
  • Grant reads the current role first:
    • already in the target tier: no write, GrantAlreadyExists (the ticket's bug).
    • current role is an admin role (SiteAdministrator, SiteAdministratorCreator, SiteAdministratorExplorer, or ServerAdministrator) outside the tier: refused with FailedPrecondition, no write. A license grant must not silently remove admin rights. Use the Site Role entitlement to assign a SiteAdministrator* role; this connector does not assign ServerAdministrator.
    • otherwise (non-admin): writes the tier's base role; if the previous role belongs to a recognized license tier, emits GrantReplaced naming that previous license grant.
    • user not found: NotFound.
  • Revoke reads the current role first: 404, a role no longer in the tier, or an already-Unlicensed user revoking the Unlicensed tier returns GrantAlreadyRevoked without writing; otherwise it sets Unlicensed as before.
  • License tiers share the static tableau-license exclusion group; entitlement and grant IDs are unchanged. This supplies the entitlement metadata required for C1 to process the existing GrantReplaced response when exclusion groups are enabled and ingested. No default tier, ordering, or automatic historical-assignment cleanup is introduced.
  • README, docs/connector.mdx, and docs/docs-info.md describe tier-aware provisioning, no-op cases, admin restrictions, and the Unlicensed floor state.

Limits / open decisions

  • Refusing admin roles on a cross-tier grant is a judgment call (the alternative is to replace them, as the Jamf connector does for non-admin sets). Chosen because the ticket's impact is a grant that silently reduces privileges.
  • Revoke still moves an admin to Unlicensed when they hold the tier (including legacy SiteAdministrator and ServerAdministrator, which this connector does not assign); that is the explicit meaning of revoke, it is documented in the README and connector.mdx, and an admin guard on Revoke is a follow-up.
  • Revoking the unlicensed tier from an already-Unlicensed user is a successful no-op with GrantAlreadyRevoked, preserving read annotations and sending no PUT. Returning an error would create an unactionable offboarding failure. Unlicensed is the floor role: the user remains Unlicensed and the next sync will show the Unlicensed entitlement in C1 again. This reports the correct current state; the no-op does not remove the floor entitlement.
  • The Site Role entitlement (site.go) has the same missing idempotency annotations; not changed here.
  • A cross-tier grant emits one GrantReplaced (the previous license grant). The same write also changes the user's site-role grant (site:<id>:<role>), which is not reported: ConductorOne reads a single GrantReplaced per response and only resolves it against exclusion-group bindings, so a second one would be ignored. The site-role grant reconciles on the next sync.
  • The read-then-write is not atomic.

Testing

  • 735528db: build, full unit suite, vet and golangci-lint v2.14.0 pass. An SDK sync to a temporary c1z verifies all four tier IDs and their shared exclusion group; removing the annotation makes the test fail. Same-tier, refused, and failed grants emit no replacement annotation. README, connector docs and code comments were shortened without changing provisioning policy. This revision used mocks only (no live seat/grant or C1 rollout validation).

  • Unit (httptest, assert method/body/count): already-in-tier grant for Creator, SiteAdministratorCreator, ServerAdministrator, Explorer, SiteAdministratorExplorer, ExplorerCanPublish (no PUT, GrantAlreadyExists); cross-tier non-admin (one PUT + GrantReplaced); cross-tier admin (FailedPrecondition, no PUT); unknown user; revoke stale / held / 404; client GetUser 200 and 404.

  • Also covered: GetUser 500 (error, no PUT), a role outside every tier (one PUT, no GrantReplaced), ExplorerCanPublish to Creator (GrantReplaced for the explorer grant), rate-limit annotations preserved on every return path, and revoke of the Unlicensed tier.

  • go build ./..., go vet ./..., go test ./... pass.

  • Live, Tableau Cloud trial (non-prod), role read back via REST after each step: bug reproduced on main (SiteAdministratorCreator -> Creator, exit 0); with the fix the role is preserved for SiteAdministratorCreator, SiteAdministratorExplorer and ExplorerCanPublish, a cross-tier grant to an admin is refused, a non-admin cross-tier grant replaces, a held revoke sets Unlicensed, and a revoke from a stale bundle (role changed out of band to Viewer) leaves Viewer untouched.

  • Historical live evidence on earlier commit b3a60bcb: the same run was repeated, including the old Unlicensed rejection (FailedPrecondition, role unchanged). That floor-tier behavior is superseded by the no-op decision above; this was not a live test of the latest revision.

  • Latest Unlicensed no-op regression: TestLicenseRevoke_UnlicensedTier/user_is_unlicensed failed before the fix and passes after it, asserting nil error, GrantAlreadyRevoked, zero PUTs and preserved rate-limit annotations. Full unit suite, build and vet pass. The earlier local-linter toolchain limitation is resolved by using CI's v2.14.0 (see current verification above).

  • Not verified live: the latest Unlicensed no-op behavior, ServerAdministrator (not assignable on Cloud), and the annotations on the wire (asserted in unit tests only).

🤖 Generated with AI

@linear-code

linear-code Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

CXH-2368

CXH-3520

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 523663c5215c

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

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

Review Summary

licenseBuilder.Grant/Revoke now read the user's current site role through a new Client.GetUser (single-user GET, documented in the client.go endpoint header) before writing anything. Grant becomes a no-op with GrantAlreadyExists when the role already falls in the target tier's RolesPerLicense set. It refuses an admin role on a cross-tier grant with FailedPrecondition. In every other case it writes the tier's base role and attaches GrantReplaced for the previous tier. Revoke returns GrantAlreadyRevoked on a 404 or when the role is outside the tier, and writes Unlicensed otherwise.

I scanned the full diff for security and correctness and read all of license.go, doRequest, and Client. I also confirmed three behaviors this depends on:

  • status.Code unwraps the %w-wrapped uhttp error (grpc v1.83.2 Convert → FromError), so IsNotFoundError works.
  • GrantReplaced exists in the vendored baton-sdk v0.40.0.
  • The expected replaced-grant ID format matches grant.NewGrantID.

The repo-local criteria were applied:

  • Provisioning (PR1–PR3, P1–P4): the principal comes from principal.Id, the entitlement from entitlement.Resource.Id, and there is no ParentResourceId use. Errors carry gRPC codes, and already-exists/already-revoked are handled.
  • Logging (L1–L7): a Debug line on no-ops; a one-shot, customer-actionable Warn on an admin refusal.
  • Data-loss rules (S1–S6): not triggered. No sync method changed, and the 404 skip is in Revoke, not a resource-producing loop.

Docs (README, docs/connector.mdx) were updated for the behavior change. No go.mod/go.sum changes. Tests cover each of the branches above with exact PUT counts and bodies. No coverage gaps.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit b3a60bcb21b2

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

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

Review Summary

licenseBuilder.Grant/Revoke now read the user's current site role through a new Client.GetUser (single-user GET, documented in the client.go endpoint header) before writing anything. Grant becomes a no-op with GrantAlreadyExists when the role already falls in the target tier's RolesPerLicense set. It refuses an admin role on a cross-tier grant with FailedPrecondition. In every other case it writes the tier's base role and attaches GrantReplaced for the previous tier. Revoke returns GrantAlreadyRevoked on a 404 or when the role is outside the tier, and writes Unlicensed otherwise.

I scanned the full diff for security and correctness and read all of license.go, doRequest, and Client. I also confirmed three behaviors this depends on:

  • status.Code unwraps the %w-wrapped uhttp error (grpc v1.83.2 Convert → FromError), so IsNotFoundError works.
  • GrantReplaced exists in the vendored baton-sdk v0.40.0.
  • The expected replaced-grant ID format matches grant.NewGrantID.

The repo-local criteria were applied:

  • Provisioning (PR1–PR3, P1–P4): the principal comes from principal.Id, the entitlement from entitlement.Resource.Id, and there is no ParentResourceId use. Errors carry gRPC codes, and already-exists/already-revoked are handled.
  • Logging (L1–L7): a Debug line on no-ops; a one-shot, customer-actionable Warn on an admin refusal.
  • Data-loss rules (S1–S6): not triggered. No sync method changed, and the 404 skip is in Revoke, not a resource-producing loop.

Docs (README, docs/connector.mdx) were updated for the behavior change. No go.mod/go.sum changes. Tests cover each of the branches above with exact PUT counts and bodies. No coverage gaps.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.


Reviewed commit: 523663c5215c

@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 — see the full review report

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit b3a60bcb21b2

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since 523663c
View review run

Review Summary

License Grant/Revoke now read the user's current site role (Client.GetUser) before writing:

  • Grant, user already in the tier: no write; returns GrantAlreadyExists.
  • Grant, admin role in another tier: refused with FailedPrecondition.
  • Grant, non-admin in another tier: writes the tier's base role and returns GrantReplaced.
  • Revoke, user gone (404) or no longer in the tier: no write; returns GrantAlreadyRevoked.
  • Revoke of the Unlicensed tier: rejected.

mergeAnnotations passes rate-limit annotations through on every return path, and isNotFoundError moved to client.IsNotFoundError. The new commits since 523663c add that annotation merging, the Unlicensed-revoke rejection, more tests, and doc wording.

I scanned the full PR diff for security and correctness and checked it against the source:

  • The GrantReplaced ID matches what Grants() emits (strings.ToLower(tier) license IDs, memberEntitlement).
  • tierForRole agrees with RolesPerLicense.
  • Renaming the Revoke parameter to g keeps the grant package from being shadowed.

How the trusted repo-local criteria applied:

  • Provisioning (PR1–PR3, P1–P4): principal from principal.Id / g.Principal.Id, entitlement from entitlement.Resource.Id, no ParentResourceId use. Errors carry gRPC codes, and already-exists/already-revoked are handled.
  • E1/E2: errors go through uhttp, so no manual wrapping is needed.
  • Log levels (L1b/L3): the no-op paths log at Debug.
  • Section S: not triggered, since List, Entitlements and Grants did not change.
  • D1–D4: docs/connector.mdx and the README were updated to match.
  • Tests: they cover every branch, including write counts and annotation propagation.

The judgment calls listed in the PR body are deliberate and documented, so I did not raise them as defects: the admin refusal on cross-tier grants, and Revoke unlicensing an admin who holds the tier. No new issues were found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 86d57e385063

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since 523663c
View review run

Review Summary

License Grant/Revoke now read the user's current site role (Client.GetUser) before writing:

  • Grant, user already in the tier: no write; returns GrantAlreadyExists.
  • Grant, admin role in another tier: refused with FailedPrecondition.
  • Grant, non-admin in another tier: writes the tier's base role and returns GrantReplaced.
  • Revoke, user gone (404) or no longer in the tier: no write; returns GrantAlreadyRevoked.
  • Revoke of the Unlicensed tier: rejected.

mergeAnnotations passes rate-limit annotations through on every return path, and isNotFoundError moved to client.IsNotFoundError. The new commits since 523663c add that annotation merging, the Unlicensed-revoke rejection, more tests, and doc wording.

I scanned the full PR diff for security and correctness and checked it against the source:

  • The GrantReplaced ID matches what Grants() emits (strings.ToLower(tier) license IDs, memberEntitlement).
  • tierForRole agrees with RolesPerLicense.
  • Renaming the Revoke parameter to g keeps the grant package from being shadowed.

How the trusted repo-local criteria applied:

  • Provisioning (PR1–PR3, P1–P4): principal from principal.Id / g.Principal.Id, entitlement from entitlement.Resource.Id, no ParentResourceId use. Errors carry gRPC codes, and already-exists/already-revoked are handled.
  • E1/E2: errors go through uhttp, so no manual wrapping is needed.
  • Log levels (L1b/L3): the no-op paths log at Debug.
  • Section S: not triggered, since List, Entitlements and Grants did not change.
  • D1–D4: docs/connector.mdx and the README were updated to match.
  • Tests: they cover every branch, including write counts and annotation propagation.

The judgment calls listed in the PR body are deliberate and documented, so I did not raise them as defects: the admin refusal on cross-tier grants, and Revoke unlicensing an admin who holds the tier. No new issues were found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.


Reviewed commit: b3a60bcb21b2

@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 — see the full review report

Comment thread pkg/connector/license.go Resolved
Comment thread pkg/connector/license_test.go Resolved
Comment thread pkg/connector/license_test.go Resolved
Comment thread pkg/connector/license_test.go Resolved
Comment thread README.md Resolved
Comment thread docs/connector.mdx Resolved
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 86d57e385063

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since b3a60bc
View review run

Review Summary

The PR makes license Grant/Revoke read the user's current site role (new Client.GetUser) before writing. A grant of a tier the user already holds becomes a no-op that returns GrantAlreadyExists. A cross-tier grant to an admin role is refused with FailedPrecondition. A cross-tier grant to a non-admin writes the tier's base role and returns GrantReplaced. A stale or 404 revoke returns GrantAlreadyRevoked. The new commit (86d57e3) only rewords comments in license.go and license_test.go, plus one sentence in both README.md and docs/connector.mdx, so behavior is unchanged. I scanned the full PR diff again for security and correctness and found no new issues. The GrantReplaced IDs match what Grants() emits. tierForRole agrees with RolesPerLicense, including SiteAdministrator → Explorer. Read errors other than 404 never fall through to a write.

How the repo-local criteria applied:

  • PR1–PR3 / P1–P6: the principal comes from principal.Id / g.Principal.Id and the entitlement from entitlement.Resource.Id. Nothing reads ParentResourceId. Errors carry gRPC codes, and already-exists / already-revoked are both handled.
  • E1/E2: calls go through uhttp, so no manual wrapping is needed.
  • Section S: not triggered, because List/Entitlements/Grants are unchanged. The 404 → GrantAlreadyRevoked in Revoke is a provisioning path, not a resource-producing loop.
  • L-rules: the admin-refusal Warn is one-shot and actionable, which is acceptable.
  • D1–D4: the docs were updated to match.

No coverage gaps: the incremental artifact was not partial, and no go.mod/go.sum or generated files changed.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 7296c89d4710

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since b3a60bc
View review run

Review Summary

The PR makes license Grant/Revoke read the user's current site role (new Client.GetUser) before writing. A grant of a tier the user already holds becomes a no-op that returns GrantAlreadyExists. A cross-tier grant to an admin role is refused with FailedPrecondition. A cross-tier grant to a non-admin writes the tier's base role and returns GrantReplaced. A stale or 404 revoke returns GrantAlreadyRevoked. The new commit (86d57e3) only rewords comments in license.go and license_test.go, plus one sentence in both README.md and docs/connector.mdx, so behavior is unchanged. I scanned the full PR diff again for security and correctness and found no new issues. The GrantReplaced IDs match what Grants() emits. tierForRole agrees with RolesPerLicense, including SiteAdministrator → Explorer. Read errors other than 404 never fall through to a write.

How the repo-local criteria applied:

  • PR1–PR3 / P1–P6: the principal comes from principal.Id / g.Principal.Id and the entitlement from entitlement.Resource.Id. Nothing reads ParentResourceId. Errors carry gRPC codes, and already-exists / already-revoked are both handled.
  • E1/E2: calls go through uhttp, so no manual wrapping is needed.
  • Section S: not triggered, because List/Entitlements/Grants are unchanged. The 404 → GrantAlreadyRevoked in Revoke is a provisioning path, not a resource-producing loop.
  • L-rules: the admin-refusal Warn is one-shot and actionable, which is acceptable.
  • D1–D4: the docs were updated to match.

No coverage gaps: the incremental artifact was not partial, and no go.mod/go.sum or generated files changed.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.


Reviewed commit: 86d57e385063

@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 — see the full review report

@mateoHernandez123 mateoHernandez123 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.

LGTM — the approach is right, and the inline notes are mostly nits.

Comment thread pkg/client/client.go Resolved
Comment thread pkg/connector/license.go Resolved
Comment thread pkg/connector/license.go Resolved
Comment thread pkg/client/client_test.go Resolved
@sergiocorral-conductorone
sergiocorral-conductorone requested a review from a team October 27, 2026 00:31
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 7296c89d4710

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since 86d57e3
View review run

Review Summary

The PR makes license Grant and Revoke read the user's current site role (via the new Client.GetUser) before writing. A grant to a user already in the tier is now a no-op that returns GrantAlreadyExists. A cross-tier grant to an admin is refused. A non-admin cross-tier grant writes the tier's base role and emits GrantReplaced. A revoke whose target is already gone returns GrantAlreadyRevoked.

What the new commits change:

  • GetUser now passes uhttp.WithNoCache() (pkg/client/client.go:295) through a new variadic extraOpts on doRequest. In the vendored SDK, wrapper.go:530 skips the GET cache when it sees Cache-Control: no-cache, so provisioning reads are no longer stale after this client's own writes.
  • The license test mock now applies PUTs to its user map, guarded by a mutex. Two tests cover same-client grant→revoke and a role changed out of band.
  • The ServerAdministrator wording is updated in the README, connector.mdx, docs-info.md and site.go.

Coverage: I scanned the full PR diff for security and correctness, not just the incremental artifact, which was not partial.

How the trusted criteria applied:

  • P1–P6 / PR1–PR3: WHO comes from principal.Id / g.Principal.Id and WHAT from entitlement.Resource.Id. Nothing reads ParentResourceId.
  • P3 / E1–E3: already-exists and already-revoked are both handled. Refusals carry gRPC codes, and HTTP errors come back through uhttp.
  • Section S (S1–S6): not triggered. List/Entitlements/Grants are unchanged, and the 404 → GrantAlreadyRevoked path is in provisioning, not a resource-producing loop.
  • L1/L7: the admin-refusal Warn fires once and the customer can act on it. The other new logs are at Debug.
  • B1–B10: no identifier or entitlement-slug changes. GetUser hits an existing endpoint already listed in the client header, so there are no new scopes.
  • D1–D4: the docs are updated in this PR.
  • Dependencies: go.mod and go.sum are unchanged.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit a1ea63ce1d6f

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base d0c99a7d66c0.
Review mode: incremental since 86d57e3
View review run

Review Summary

The PR makes license Grant and Revoke read the user's current site role (via the new Client.GetUser) before writing. A grant to a user already in the tier is now a no-op that returns GrantAlreadyExists. A cross-tier grant to an admin is refused. A non-admin cross-tier grant writes the tier's base role and emits GrantReplaced. A revoke whose target is already gone returns GrantAlreadyRevoked.

What the new commits change:

  • GetUser now passes uhttp.WithNoCache() (pkg/client/client.go:295) through a new variadic extraOpts on doRequest. In the vendored SDK, wrapper.go:530 skips the GET cache when it sees Cache-Control: no-cache, so provisioning reads are no longer stale after this client's own writes.
  • The license test mock now applies PUTs to its user map, guarded by a mutex. Two tests cover same-client grant→revoke and a role changed out of band.
  • The ServerAdministrator wording is updated in the README, connector.mdx, docs-info.md and site.go.

Coverage: I scanned the full PR diff for security and correctness, not just the incremental artifact, which was not partial.

How the trusted criteria applied:

  • P1–P6 / PR1–PR3: WHO comes from principal.Id / g.Principal.Id and WHAT from entitlement.Resource.Id. Nothing reads ParentResourceId.
  • P3 / E1–E3: already-exists and already-revoked are both handled. Refusals carry gRPC codes, and HTTP errors come back through uhttp.
  • Section S (S1–S6): not triggered. List/Entitlements/Grants are unchanged, and the 404 → GrantAlreadyRevoked path is in provisioning, not a resource-producing loop.
  • L1/L7: the admin-refusal Warn fires once and the customer can act on it. The other new logs are at Debug.
  • B1–B10: no identifier or entitlement-slug changes. GetUser hits an existing endpoint already listed in the client header, so there are no new scopes.
  • D1–D4: the docs are updated in this PR.
  • Dependencies: go.mod and go.sum are unchanged.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.


Reviewed commit: 7296c89d4710

@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 — see the full review report

@sergiocorral-conductorone
sergiocorral-conductorone force-pushed the sergiocorral/cxh-2368-baton-tableau-fix-license-grant-rewriting-the-site-role-of-a branch from c2aab5d to 42f1601 Compare October 29, 2026 15:31
@sergiocorral-conductorone
sergiocorral-conductorone changed the base branch from main to staging October 29, 2026 15:31
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 42f1601b5e5c

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: none loaded - base ref staging is not the default branch main for .claude/skills/ci-review.md.
Review mode: full
View review run

Review Summary

License Grant and Revoke now read the user's current site role before writing. The read uses the new Client.GetUser, which sends uhttp.WithNoCache(); the vendored SDK skips its cache when Cache-Control: no-cache is set (wrapper.go:759).

Grant

  • Same tier: no write; returns GrantAlreadyExists. This is the reported bug, and it is fixed.
  • Admin outside the tier: refused with FailedPrecondition, no write.
  • Any other user: writes the tier's base role. If the previous role belonged to a known tier, it emits GrantReplaced. That grant ID comes from grant.NewGrantID and matches the IDs Grants() emits at sync.
  • Unknown user: returns NotFound.

Revoke

  • Missing user, user no longer in the tier, or Unlicensed already: returns GrantAlreadyRevoked with no write.

Exclusion group: all four tiers now share the static tableau-license exclusion group. Entitlement and grant IDs are unchanged.

Coverage

  • I scanned the full PR diff for security and correctness, checking the connector mixin's P1–P6, H1–H5, R7 and B1–B10 rules.
  • Principals come from principal.Id / g.Principal.Id, and nothing reads ParentResourceId.
  • The 404 handling is a provisioning no-op, not a skip inside a sync loop.
  • No endpoints were added (GET user was already used and documented in C1), so no new scopes are needed.
  • go.mod/go.sum are unchanged, and every SDK API the PR uses exists in vendored baton-sdk v0.43.0.
  • No repo-local criteria loaded.
  • I only skimmed the test and docs diffs. They match the implemented behavior.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 94370d870885

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: none loaded - base ref staging is not the default branch main for .claude/skills/ci-review.md.
Review mode: full
View review run

Review Summary

License Grant and Revoke now read the user's current site role before writing. The read uses the new Client.GetUser, which sends uhttp.WithNoCache(); the vendored SDK skips its cache when Cache-Control: no-cache is set (wrapper.go:759).

Grant

  • Same tier: no write; returns GrantAlreadyExists. This is the reported bug, and it is fixed.
  • Admin outside the tier: refused with FailedPrecondition, no write.
  • Any other user: writes the tier's base role. If the previous role belonged to a known tier, it emits GrantReplaced. That grant ID comes from grant.NewGrantID and matches the IDs Grants() emits at sync.
  • Unknown user: returns NotFound.

Revoke

  • Missing user, user no longer in the tier, or Unlicensed already: returns GrantAlreadyRevoked with no write.

Exclusion group: all four tiers now share the static tableau-license exclusion group. Entitlement and grant IDs are unchanged.

Coverage

  • I scanned the full PR diff for security and correctness, checking the connector mixin's P1–P6, H1–H5, R7 and B1–B10 rules.
  • Principals come from principal.Id / g.Principal.Id, and nothing reads ParentResourceId.
  • The 404 handling is a provisioning no-op, not a skip inside a sync loop.
  • No endpoints were added (GET user was already used and documented in C1), so no new scopes are needed.
  • go.mod/go.sum are unchanged, and every SDK API the PR uses exists in vendored baton-sdk v0.43.0.
  • No repo-local criteria loaded.
  • I only skimmed the test and docs diffs. They match the implemented behavior.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.


Reviewed commit: 42f1601b5e5c

@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 — see the full review report

Comment thread pkg/connector/license_test.go Resolved
@sergiocorral-conductorone
sergiocorral-conductorone changed the base branch from staging to main November 2, 2026 17:27
Comment thread pkg/connector/license.go Resolved
Comment thread pkg/connector/license.go Resolved
Comment thread docs/connector.mdx Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 94370d870885

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

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

Review Summary

License Grant and Revoke now read the user's current site role first, using a new uncached Client.GetUser (uhttp.WithNoCache), and only then decide whether to write:

  • Grant: a user already in the target tier gets no write and GrantAlreadyExists, which fixes the reported role rewrite. An admin role outside the tier is refused with FailedPrecondition. Any other user gets a PUT, plus GrantReplaced whose ID comes from grant.NewGrantID and matches the IDs Grants() emits at sync.
  • Revoke: a missing user, a user no longer in the tier, or an already-Unlicensed user gets GrantAlreadyRevoked and no write.
  • Exclusion group: the four tiers share the static, unscoped tableau-license exclusion group. In vendored baton-sdk, an unscoped group on a static entitlement covers every resource of the type, so the tiers are mutually exclusive as intended.

I scanned the full PR diff for security and correctness. Changed code and docs were checked; the tests were scanned, not traced line by line.

How the repo-local criteria applied:

  • PR1/PR2 (provisioning): the full license.go was read. Principals come from principal.Id / g.Principal.Id, and nothing reads ParentResourceId.
  • P3 (idempotency): now satisfied for licenses.
  • S/R7 (sync data loss): sync paths are unchanged, so these don't apply.
  • L-rules (logging): new logs are Debug no-op lines, which is appropriate.
  • E-rules (error codes): the new admin refusal uses a gRPC code. The unknown license paths don't (see suggestion).
  • Docs and dependencies: docs/connector.mdx is updated, and go.mod/go.sum are unchanged.

The PR describes the remaining limits itself: Revoke still demotes admins, read-then-write isn't atomic, and site.go still lacks idempotency annotations. I didn't treat these as defects.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New, pkg/connector/license.go:329 (also Grant at :277): the unknown license errors are plain fmt.Errorf, so they surface as codes.Unknown rather than a typed status (P4/E3). Confidence: high; impact: low.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/license.go`:
- Around lines 275-278 (Grant) and 327-330 (Revoke): the "unknown license %s" errors are returned via fmt.Errorf and carry no gRPC status code. Replace them with status.Errorf(codes.InvalidArgument, "unknown license %s", ...) so the platform sees a typed caller error (codes and status are already imported in this file).

Comment thread pkg/connector/license.go Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit e04a03fcef81

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

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

Review Summary

License Grant and Revoke now read the user's current site role first, using a new uncached Client.GetUser (uhttp.WithNoCache), and only then decide whether to write:

  • Grant: a user already in the target tier gets no write and GrantAlreadyExists, which fixes the reported role rewrite. An admin role outside the tier is refused with FailedPrecondition. Any other user gets a PUT, plus GrantReplaced whose ID comes from grant.NewGrantID and matches the IDs Grants() emits at sync.
  • Revoke: a missing user, a user no longer in the tier, or an already-Unlicensed user gets GrantAlreadyRevoked and no write.
  • Exclusion group: the four tiers share the static, unscoped tableau-license exclusion group. In vendored baton-sdk, an unscoped group on a static entitlement covers every resource of the type, so the tiers are mutually exclusive as intended.

I scanned the full PR diff for security and correctness. Changed code and docs were checked; the tests were scanned, not traced line by line.

How the repo-local criteria applied:

  • PR1/PR2 (provisioning): the full license.go was read. Principals come from principal.Id / g.Principal.Id, and nothing reads ParentResourceId.
  • P3 (idempotency): now satisfied for licenses.
  • S/R7 (sync data loss): sync paths are unchanged, so these don't apply.
  • L-rules (logging): new logs are Debug no-op lines, which is appropriate.
  • E-rules (error codes): the new admin refusal uses a gRPC code. The unknown license paths don't (see suggestion).
  • Docs and dependencies: docs/connector.mdx is updated, and go.mod/go.sum are unchanged.

The PR describes the remaining limits itself: Revoke still demotes admins, read-then-write isn't atomic, and site.go still lacks idempotency annotations. I didn't treat these as defects.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New, pkg/connector/license.go:329 (also Grant at :277): the unknown license errors are plain fmt.Errorf, so they surface as codes.Unknown rather than a typed status (P4/E3). Confidence: high; impact: low.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/license.go`:
- Around lines 275-278 (Grant) and 327-330 (Revoke): the "unknown license %s" errors are returned via fmt.Errorf and carry no gRPC status code. Replace them with status.Errorf(codes.InvalidArgument, "unknown license %s", ...) so the platform sees a typed caller error (codes and status are already imported in this file).

Reviewed commit: 94370d870885

@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 — see the full review report

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit e04a03fcef81

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 28710c51d162.
Review mode: incremental since 94370d8
View review run

Review Summary

What the PR does. License Grant and Revoke now read the user's current site role before writing anything:

  • Same-tier grant: the user's role is kept and the grant returns GrantAlreadyExists, which fixes the reported rewrite of SiteAdministratorCreator.
  • Cross-tier grant to an admin: refused with FailedPrecondition.
  • Any other cross-tier grant: the role is written and the grant returns GrantReplaced.
  • Revoke: a missing user, a stale grant or the Unlicensed floor returns GrantAlreadyRevoked with no write.
  • Exclusion group: the four tiers share a tableau-license exclusion group.

This push. The new commit switches both unknown license errors in license.go (:277 and :329) from fmt.Errorf to status.Errorf(codes.InvalidArgument, ...). It adds TestLicense_UnknownLicense, which checks the code and that no PUT is sent.

Coverage. I scanned the full PR diff for security and correctness: client.go (GetUser, IsNotFoundError, doRequest options), helpers.go (mergeAnnotations), group.go, site.go, license.go and the tests.

Repo-local criteria. I read all of license.go for the provisioning rules (PR1/PR2/PR3). Principals come from principal.Id and g.Principal.Id, and nothing reads ParentResourceId. On logging, the new lines are Debug no-op messages, and no line both logs at Error and returns the error. On error codes, provisioning errors are now typed gRPC statuses or wrap uhttp errors with %w. Section S doesn't apply: Grants() returns any list error, and the only skip is a role filter, not an error branch. go.mod/go.sum are unchanged, and docs/connector.mdx is updated.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Resolved prior findings

  • Fixed: the unknown license errors used to come back as codes.Unknown. Both now return codes.InvalidArgument (pkg/connector/license.go:277 for Grant and :329 for Revoke), and TestLicense_UnknownLicense in pkg/connector/license_test.go covers them.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXH-2368] - Fix license grant rewriting the site role of a user who already holds the tier

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 28710c51d162.
Review mode: incremental since 94370d8
View review run

Review Summary

What the PR does. License Grant and Revoke now read the user's current site role before writing anything:

  • Same-tier grant: the user's role is kept and the grant returns GrantAlreadyExists, which fixes the reported rewrite of SiteAdministratorCreator.
  • Cross-tier grant to an admin: refused with FailedPrecondition.
  • Any other cross-tier grant: the role is written and the grant returns GrantReplaced.
  • Revoke: a missing user, a stale grant or the Unlicensed floor returns GrantAlreadyRevoked with no write.
  • Exclusion group: the four tiers share a tableau-license exclusion group.

This push. The new commit switches both unknown license errors in license.go (:277 and :329) from fmt.Errorf to status.Errorf(codes.InvalidArgument, ...). It adds TestLicense_UnknownLicense, which checks the code and that no PUT is sent.

Coverage. I scanned the full PR diff for security and correctness: client.go (GetUser, IsNotFoundError, doRequest options), helpers.go (mergeAnnotations), group.go, site.go, license.go and the tests.

Repo-local criteria. I read all of license.go for the provisioning rules (PR1/PR2/PR3). Principals come from principal.Id and g.Principal.Id, and nothing reads ParentResourceId. On logging, the new lines are Debug no-op messages, and no line both logs at Error and returns the error. On error codes, provisioning errors are now typed gRPC statuses or wrap uhttp errors with %w. Section S doesn't apply: Grants() returns any list error, and the only skip is a role filter, not an error branch. go.mod/go.sum are unchanged, and docs/connector.mdx is updated.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Resolved prior findings

  • Fixed: the unknown license errors used to come back as codes.Unknown. Both now return codes.InvalidArgument (pkg/connector/license.go:277 for Grant and :329 for Revoke), and TestLicense_UnknownLicense in pkg/connector/license_test.go covers them.

Reviewed commit: e04a03fcef81

@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 — see the full review report

Comment thread pkg/connector/license.go
// NotFound for a missing user, GrantAlreadyExists (no write) for a role already
// in the tier, FailedPrecondition for an admin role, else a write that reports
// GrantReplaced when the previous role belonged to a known tier.
func (l *licenseBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) {

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.

Grant keeps the V1 signature, so the grant it creates never reaches C1. The SDK wraps a V1 provisioner in an adapter that drops them: resource_provisioner.go#L160-L167 (return nil, annos, nil). C1 reads that list to create the AppEntitlementUserBinding, so the replacement is recorded and the new binding isn't — with no error anywhere.

The signature predates this PR. What puts it in scope is that WithExclusionGroup (line 220) and the GrantReplaced on line 314 are new here, and they're the half that needs the grant to arrive.

Same shape, already solved in baton-claude-enterprise organizations.go#L201-L261: nil grants on the already-satisfied path, and AppendGrantReplaced plus []*v2.Grant{grant.NewGrant(...)} on the replace path. baton-brex titles.go#L66-L87 is the compact version.

func (l *licenseBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error)

Return nil grants on the GrantAlreadyExists and FailedPrecondition paths, the new grant on the write path. Revoke stays as is — the SDK has no V2 variant for it.

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.

8 participants