Skip to content

Add license resource type with grant/revoke support#26

Open
c1-dev-bot[bot] wants to merge 3 commits into
mainfrom
feat/sync-license-types
Open

Add license resource type with grant/revoke support#26
c1-dev-bot[bot] wants to merge 3 commits into
mainfrom
feat/sync-license-types

Conversation

@c1-dev-bot
Copy link
Copy Markdown

@c1-dev-bot c1-dev-bot Bot commented May 5, 2026

Summary

  • Adds a new license resource type that syncs Zoom license tiers (Basic, Licensed, None) as first-class resources
  • Creates entitlements linking each user to their current license type during sync
  • Implements Grant action to upgrade/assign a user's license via PATCH /v2/users/{userId}
  • Implements Revoke action to downgrade a user to Basic (type 1)

This enables license visibility in C1, SSAR flows for license requests, automated license reclamation during offboarding, and inclusion of license assignments in access reviews.

Fixes: CXH-1446

Changes

pkg/connector/license.go (new)

  • licenseResourceType implementing ResourceSyncerV2 + ResourceProvisionerV2
  • List() returns three static license resources: basic, licensed, none
  • Entitlements() creates an "assigned" entitlement per license, grantable to users
  • Grants() paginates through users, creating grants for those matching the license type
  • Grant() updates user type via API, with idempotency (GrantAlreadyExists)
  • Revoke() downgrades user to Basic, with idempotency (GrantAlreadyRevoked)

pkg/zoom/client.go

  • UpdateUserType() method: PATCH /v2/users/{userId} with {"type": N}

pkg/connector/connector.go

  • Added resourceTypeLicense definition with required API scopes
  • Registered licenseBuilder in ResourceSyncers()
  • Updated connector description

Test plan

  • Verify sync produces license resources and user-to-license grants
  • Verify granting licensed license updates user type to 2
  • Verify revoking licensed license downgrades user type to 1 (Basic)
  • Verify idempotent grant (user already has target license) returns GrantAlreadyExists
  • Verify idempotent revoke (user doesn't have target license) returns GrantAlreadyRevoked
  • Verify no new API scopes are required

Automated PR Notice

This PR was automatically created by c1-dev-bot as a potential implementation.

This code requires:

  • Human review of the implementation approach
  • Manual testing to verify correctness
  • Approval from the appropriate team before merging

@c1-dev-bot c1-dev-bot Bot requested a review from a team May 5, 2026 20:27
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 5, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Claude finished @c1-dev-bot[bot]'s task in 1m 58s —— View job


  • Read and analyze all changed files
  • Review pkg/connector/license.go (new file)
  • Review pkg/zoom/client.go changes
  • Review pkg/connector/connector.go changes
  • Check for consistency with existing codebase patterns
  • Post detailed review feedback

Review: Add license resource type with grant/revoke support

Overall this is a clean implementation that follows the established patterns in the codebase well. The pagination in Grants() mirrors the user builder, the Grant/Revoke idempotency is correctly implemented, and the error handling is consistent. Below are the issues I found, ordered by severity.


Issues

1. Missing UnnasignedUser (type 3) license — users will have no license grant

pkg/zoom/models.go defines UnnasignedUser UserType = 3, but licenseTypes in license.go:26-29 only covers Basic (1), Licensed (2), and None (99). Any user with type 3 will silently have no license grant during sync. This is either a bug (type 3 should be included) or needs explicit justification.

If intentional, consider adding a comment. If not:

var licenseTypes = []licenseInfo{
    {ID: "basic", DisplayName: "Basic", UserType: int(zoom.BasicUser)},
    {ID: "licensed", DisplayName: "Licensed", UserType: int(zoom.LicensedUser)},
    {ID: "unassigned", DisplayName: "Unassigned", UserType: int(zoom.UnnasignedUser)},
    {ID: "none", DisplayName: "None", UserType: int(zoom.NoneUser)},
}

Fix this →

2. Revoking the "None" license upgrades user to Basic — semantically surprising

Revoke() at license.go:219 always downgrades to BasicUser (type 1). Revoking a "None" license (type 99) would upgrade the user to Basic, which is semantically backwards — "None" represents the absence of a license, and removing that absence gives them a license.

Consider guarding against revoking the "basic" or "none" licenses, or at least the "none" type:

if targetType == int(zoom.BasicUser) {
    return nil, fmt.Errorf("baton-zoom: cannot revoke basic license (already lowest tier)")
}

3. No inactive user check in Grant/Revoke

The role builder (role.go:165-167, role.go:205-206) explicitly checks user.Status == userStatusInactive and returns an error before attempting mutations. The license builder fetches the user in both Grant() and Revoke() but does not check the user's status. If updating a license on an inactive user is unsupported by the Zoom API, this would produce a confusing 400 error instead of a clear message.

Fix this →

4. resourceTypeLicense missing a Trait

All other non-user resource types define a trait — TRAIT_GROUP for groups/contact groups, TRAIT_ROLE for roles. The license resource type at connector.go:97-107 has no Traits field. While there's no perfect built-in trait for "license," this may cause issues with SDK consumers that expect a trait to be set. Consider whether TRAIT_ROLE is an appropriate fit, since licenses represent a permission tier.


Minor / Nits

5. map[string]interface{} in UpdateUserType (client.go:395) — A typed struct would be slightly cleaner, but this matches the existing pattern used by AddGroupMembers and AddGroupAdmins, so it's consistent.

6. assignedEntitlement constant (license.go:18) — Defined at package scope. If a future builder also needs an "assigned" entitlement, this will need to be shared or renamed. Fine for now.


What looks good

  • Pagination in Grants() correctly mirrors the user builder's dual-status pagination bag pattern, handling both active and (optionally) inactive users.
  • Grant/Revoke idempotency properly returns GrantAlreadyExists / GrantAlreadyRevoked annotations with nil errors, following the CLAUDE.md pattern.
  • HTTP response handling consistently checks for nil resp before closing on error paths.
  • UpdateUserType client method is clean and follows established patterns in client.go.
  • Connector registration in connector.go is minimal and correct — resource type definition with appropriate scopes, description update, and syncer registration.

Summary

The implementation is solid and consistent with the codebase. The main concern is the missing type 3 (Unassigned) license — users with that type will have no license grant in sync output. The revoke-None-upgrades-to-Basic semantic and the missing inactive user guards are worth addressing before merge. The rest is minor.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

Claude finished @c1-dev-bot[bot]'s task in 1m 20s —— View job


  • Read and analyze all changed files
  • Review pkg/connector/license.go (new file)
  • Review pkg/zoom/client.go changes
  • Review pkg/connector/connector.go changes
  • Check for consistency with existing codebase patterns
  • Post detailed review feedback

Review: Add license resource type with grant/revoke support

The implementation is well-structured and follows existing codebase patterns closely. The pagination in Grants() correctly mirrors the user builder's dual-status bag pattern, Grant/Revoke idempotency is properly implemented, and the UpdateUserType client method is consistent with other mutation methods. Below are the issues found, ordered by severity.


Issues

1. Missing UnnasignedUser (type 3) license — users will have no license grant ⚠️

pkg/zoom/models.go:14 defines UnnasignedUser UserType = 3, but licenseTypes in license.go:26-29 only covers Basic (1), Licensed (2), and None (99). Any user with type 3 will silently have no license grant during sync. This means their license state is invisible to C1.

var licenseTypes = []licenseInfo{
    {ID: "basic", DisplayName: "Basic", UserType: int(zoom.BasicUser)},
    {ID: "licensed", DisplayName: "Licensed", UserType: int(zoom.LicensedUser)},
    {ID: "unassigned", DisplayName: "Unassigned", UserType: int(zoom.UnnasignedUser)},
    {ID: "none", DisplayName: "None", UserType: int(zoom.NoneUser)},
}

Fix this →

2. Revoking "None" (type 99) or "Basic" (type 1) license upgrades user to Basic — semantically wrong ⚠️

Revoke() at license.go:219 unconditionally downgrades to BasicUser (type 1). This creates two problematic cases:

  • Revoking "None" (99): This upgrades the user from "no license" to "Basic" — the opposite of what "revoke" means semantically.
  • Revoking "Basic" (1): The idempotency check at line 215 catches this (user already has type 1 → GrantAlreadyRevoked), so it's safe but could still be confusing if Basic is considered the floor.

Consider guarding against revoking non-downgradeable licenses:

if targetType == int(zoom.NoneUser) {
    return nil, fmt.Errorf("baton-zoom: cannot revoke 'none' license — revoking would upgrade the user")
}

3. No inactive user check in Grant/Revoke ⚠️

The role builder (role.go:165-167, role.go:205-206) explicitly checks user.Status == userStatusInactive and returns an error before attempting mutations. The license builder fetches the user in both Grant() (line 165) and Revoke() (line 206) but never checks the user's status. If the Zoom API rejects mutations on inactive users, this would produce a confusing 400 error instead of a clear message.

Add the same guard used in role.go:

if user.Status == userStatusInactive {
    return nil, nil, fmt.Errorf("baton-zoom: cannot grant license to inactive user %s", principal.Id.Resource)
}

Fix this →

4. resourceTypeLicense missing a Trait

All other non-user resource types in connector.go define a trait — TRAIT_GROUP for groups/contact groups (lines 39, 57), TRAIT_ROLE for roles (line 85). The license resource type at connector.go:97-107 has no Traits field. While there's no perfect built-in trait for "license," this inconsistency may cause issues with SDK consumers that expect a trait. Consider whether TRAIT_ROLE fits, since licenses represent permission tiers. The baton_capabilities.json confirms the trait is missing from the serialized metadata.


Minor / Nits

5. map[string]interface{} in UpdateUserType (client.go:395) — A typed struct would be slightly cleaner, but this is consistent with the existing pattern used by AddGroupMembers (line 271) and AddGroupAdmins (line 301), so it's fine.

6. Pagination ResourceTypeID uses resourceTypeLicense.Id (license.go:102-104) — The user builder uses resourceTypeUser.Id for its pagination states. Using the license resource type ID here is correct since these are grants on license resources, but worth noting that the string is serving as an arbitrary key for the pagination bag, not a resource-type identity.

7. assignedEntitlement constant at package scope (license.go:18) — If a future builder also uses "assigned," this will need to be namespaced. Fine for now, but worth being aware of.


What looks good

  • Pagination in Grants() correctly mirrors the user builder's dual-status pagination bag pattern, handling both active and (optionally) inactive users.
  • Grant/Revoke idempotency properly returns GrantAlreadyExists / GrantAlreadyRevoked annotations with nil error, matching the CLAUDE.md pattern.
  • HTTP response handling consistently checks for nil resp before closing body on error paths (license.go:112-114, license.go:167-169, license.go:208-210).
  • UpdateUserType client method (client.go:392-409) is clean and follows established patterns.
  • Connector registration (connector.go:97-107, connector.go:220) is minimal and correct — resource type definition with appropriate scopes, description update, syncer registration.
  • Documentation (docs/connector.mdx:20) and capabilities metadata (baton_capabilities.json) are updated consistently.

Summary

The implementation is solid and consistent with the codebase. Three issues should be addressed before merge: the missing type 3 (Unassigned) license (users silently dropped from sync), the revoke-None-upgrades-to-Basic semantic surprise, and the missing inactive user guards (inconsistent with role.go). The missing trait on the license resource type is worth discussing. Everything else is clean.

c1-dev-bot[bot] added 2 commits May 21, 2026 11:03
Sync Zoom license types (Basic, Licensed, None) as first-class
resources with entitlements linking users to their current license.

Grant action updates a user's license type via PATCH /v2/users/{userId}.
Revoke action downgrades a user to Basic (type 1).

Fixes: CXH-1446
Add the license resource type entry to baton_capabilities.json and
add a Licenses row to the capabilities table in connector.mdx.
@Bencheng21 Bencheng21 force-pushed the feat/sync-license-types branch 2 times, most recently from 3de40e1 to 6a88d73 Compare May 21, 2026 21:19
Comment thread baton_capabilities.json Outdated
},
"capabilities": [
"CAPABILITY_SYNC",
"CAPABILITY_PROVISION"
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: CAPABILITY_PROVISION is declared for the license resource type, but the licenseResourceType builder in license.go does not implement Grant/Revoke methods. ConductorOne will surface provisioning actions that fail at runtime. Either remove CAPABILITY_PROVISION from this file, or add the provisioning implementation.

Comment thread pkg/connector/user.go
}

if zoom.UserType(userTypeVal.GetNumberValue()) != zoom.LicensedUser {
return nil, &resource.SyncOpResults{}, nil
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: Only LicensedUser (type 2) receives a plan_base grant. Users with Basic (1), Unassigned (3), or None (99) types will have no license relationship visible in C1. If the intent is specifically to track Licensed-tier consumption of plan_base seats, a brief comment explaining why other types are excluded would help future readers. If full license visibility is desired, consider mapping each user type to its corresponding license resource.

Comment thread pkg/connector/user.go
Id: &v2.ResourceId{
ResourceType: resourceTypeLicense.Id,
Resource: "plan_base",
},
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 plan_base resource ID is hardcoded. If the Zoom account's API response has a nil PlanBase field, license.go:List() will skip creating this resource, but user grants will still reference it — creating orphaned grants pointing to a non-existent resource. Consider either guarding this grant behind a check that plan_base exists, or documenting the assumption that plan_base is always present for accounts with licensed users.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Connector PR Review: Add license resource type with grant/revoke support

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Review mode: full
View review run

Review Summary

This PR adds a license resource type that syncs Zoom plan usage data (plan_base, plan_united, plan_zoom_rooms, etc.) as license resources with seat tracking via TRAIT_LICENSE_PROFILE, and removes SkipEntitlementsAndGrants from the user resource type so user Grants() can emit license grants linking Licensed-tier users to plan_base. The previous suggestion about the leftover user:write:user:admin scope in license capabilities has been addressed — the license resource now correctly declares only read scopes. Two prior suggestions about grant coverage and hardcoded plan_base remain applicable.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/user.go:141 — Only LicensedUser (type 2) gets a license grant; Basic, Unassigned, and None users have no license visibility in C1.
  • pkg/connector/user.go:148 — Hardcoded plan_base resource reference could create orphaned grants if the Zoom account lacks a plan_base plan.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/user.go`:
- Around line 141: The Grants() method only creates license grants for users with UserType == LicensedUser (2). Users with Basic (1), Unassigned (3), or None (99) types get no license grant, making their license state invisible in C1. Consider mapping each user type to its corresponding license resource, or add a comment explaining that only Licensed users are intentionally tracked because only they consume plan_base seats.

In `pkg/connector/user.go`:
- Around line 145-151: The grant always references a hardcoded "plan_base" resource ID. If the Zoom account doesn't have a plan_base plan (PlanBase is nil in the API response), license List() will skip creating the plan_base resource, but user Grants() will still emit grants pointing to it, creating orphaned grants. Consider guarding grant creation on plan_base existence (e.g., by checking the license plan data), or document the assumption that plan_base is always present for accounts with Licensed users.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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.

@Bencheng21 Bencheng21 force-pushed the feat/sync-license-types branch from 6a88d73 to 92ccbe1 Compare May 21, 2026 21:29
Comment thread baton_capabilities.json Outdated
Comment on lines +152 to +154
"permission": "user:read:list_users:admin"
},
{
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 license resource declares user:write:user:admin permission, but the sync-only implementation only performs a GET to /accounts/me/plans/usage. This write scope appears to be a leftover from removed provisioning support and may cause ConductorOne to request unnecessary API scopes from users.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

No blocking issues found.

@Bencheng21 Bencheng21 force-pushed the feat/sync-license-types branch from 92ccbe1 to 7d7ecdc Compare May 21, 2026 21:49
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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