Skip to content

[CXH-1571] - Add License management - Zoom Connector#28

Open
mateoHernandez123 wants to merge 3 commits into
mainfrom
mateohernandez/baton-zoom-add-license-management
Open

[CXH-1571] - Add License management - Zoom Connector#28
mateoHernandez123 wants to merge 3 commits into
mainfrom
mateohernandez/baton-zoom-add-license-management

Conversation

@mateoHernandez123

@mateoHernandez123 mateoHernandez123 commented Jun 1, 2026

Copy link
Copy Markdown

Description

  • Bug fix
  • New feature

Adds a license resource type that models Zoom's three license tiers (Basic, Licensed, On-Prem) as static resources, with user grants emitted principal-side from userBuilder.Grants and seat counts surfaced via LicenseProfileTrait for the C1 App Utilization feature (CE-720).

Closes CXH-1571.

Implementation

  • License resources are static (no /licenses endpoint in Zoom) — tiers are fixed integer codes on User.type: Basic (1), Licensed (2), On-Prem (3).
  • License is a derived resource type: grants are emitted from userBuilder.Grants (principal-side) using User.type stashed in the user resource profile during List(). licenseResourceType.Grants is a no-op. This keeps sync at O(users) instead of O(users × tiers).
  • Grant / Revoke: PATCH /v2/users/{userId} with {"type": N}. Revoke downgrades to Basic (Zoom has no "no license" state — Basic is the floor tier).
  • Idempotency uses a triple short-circuit, each backed by a single pre-flight GetUser (no extra PATCH):
    • Grant of the user's current tier → GrantAlreadyExists
    • Revoke of a tier the user no longer holds → GrantAlreadyRevoked
    • Revoke of the floor tier (Basic) → GrantAlreadyRevoked
  • LicenseProfileTrait is wired on every tier (LicenseName + EntitlementIDs = license:<id>:assigned so App Utilization can correlate consumed_seats to user grants). Seat counts (WithLicenseSeats) are added only on Licensed when purchased > 0 — Basic is uncapped (Zoom doesn't track it) and On-Prem is provisioned outside Zoom Cloud (no billing-API visibility).
  • Seat counts come from GET /v2/accounts/me/plans/usage (plan_base.hosts / plan_base.usage) — best-effort: if the billing scope is missing or the call fails, the three tiers still emit, just without seat numbers on Licensed.
  • The full reasoning (how Zoom licensing maps onto C1, why tiers are static, what's deliberately out of scope) lives in the header comment of pkg/connector/license.go:1-72.

Scopes required

Granular Server-to-Server OAuth scopes:

  • user:update:user:adminPATCH /v2/users/{userId} (required for Grant/Revoke).
  • billing:read:plan_usage:adminGET /v2/accounts/me/plans/usage (optional; enables seat-count enrichment on Licensed).

Validation

End-to-end against a live Business-Monthly tenant: sync, Grant, idempotent re-Grant, Revoke, idempotent re-Revoke (non-held tier and floor tier), and license assignment from the C1 platform UI after email-invitation acceptance. plan_base.usage matches count(User.type == 2) on every state transition.

Useful links

@mateoHernandez123 mateoHernandez123 requested a review from a team June 1, 2026 20:26
@linear-code

linear-code Bot commented Jun 1, 2026

Copy link
Copy Markdown

CXH-1571

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

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXH-1571] - Add License management - Zoom Connector

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

Review Summary

This PR adds a license resource type modeling Zoom's three user license tiers (Basic, Licensed, On-Prem) as static resources with principal-side grant emission from userBuilder.Grants, provisioning via PATCH /v2/users/{userId}, and optional seat-count enrichment from the billing API. The implementation is clean: resource type definitions are correctly extracted to resource_types.go, the SkipEntitlementsAndGrantsSkipEntitlements annotation change on User is consistent with the new grant emission, idempotency is properly handled with pre-flight GETs and GrantAlreadyExists/GrantAlreadyRevoked annotations, HTTP response nil checks follow the established codebase patterns, and the baton_capabilities.json accurately reflects the new resource type and annotation changes. No blocking issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

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

@mateoHernandez123

Copy link
Copy Markdown
Author

Thanks for the review. Triage of the 2 suggestions + the N2 finding in the summary:

  • pkg/zoom/client.go:455 (url.JoinPath consistency) — not applying. The 12 read endpoints in the client all use fmt.Sprint; url.JoinPath is only used by mutating endpoints (InviteUser, DeleteUser, PatchUserLicense). Standardizing on url.JoinPath everywhere is a separate refactor.
  • pkg/connector/license.go:277-281 (Revoke Basic short-circuit) — not applying. Returning GrantAlreadyRevoked when the user still holds Basic would contradict the connector-wide principle of "don't pre-judge user state". The fall-through PatchUserLicense(..., BasicUser) is one harmless PATCH (204) and keeps all four Grant/Revoke entry points symmetric.
  • pkg/connector/connector.go:51 (Metadata().Description) — applied in 63055c2: now reads "Connector syncing users, groups, roles, contact groups, and license tiers from Zoom to Baton.".

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

@mateoHernandez123 mateoHernandez123 dismissed github-actions[bot]’s stale review June 2, 2026 12:44

Bot re-review reasserted the same 2 suggestions already triaged: both inline threads are resolved with rationale (url.JoinPath consistency belongs to a separate refactor; Revoke-Basic short-circuit would contradict the connector-wide "don't pre-judge user state" principle). The third finding (Metadata().Description) was applied in 63055c2. No new findings.

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

Comment thread pkg/connector/license.go Outdated
Comment thread pkg/connector/license.go Outdated
Comment thread pkg/zoom/client.go
// counts from GET /v2/accounts/me/plans/usage. Requires the
// `billing:read:plan_usage:admin` scope (or the legacy `billing:read`).
func (c *Client) GetAccountPlanUsage(ctx context.Context) (*PlanUsage, *http.Response, error) {
requestURL := fmt.Sprint(c.baseURL, "/accounts/me/plans/usage")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Use url.JoinPath IMO

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not applying in this PR — keeping consistency with the existing client. All 12 read endpoints in pkg/zoom/client.go use fmt.Sprint(c.baseURL, "/path") (GetUsers, GetGroups, GetRoles, GetUser, etc.); url.JoinPath is only used by the 3 mutating endpoints (InviteUser, DeleteUser, PatchUserLicense) where it pre-existed. Migrating GetAccountPlanUsage alone would be inconsistent — it's a GET, so it follows the GET convention. If we want to standardize on url.JoinPath across the whole client, happy to do it as a separate refactor PR (touches all 12 GET callsites).

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

@mateoHernandez123 mateoHernandez123 force-pushed the mateohernandez/baton-zoom-add-license-management branch from b35f862 to 8f2e3c6 Compare June 2, 2026 14:23
Comment thread pkg/connector/license.go
Comment thread pkg/zoom/client.go

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

Comment thread pkg/connector/license.go
if user.Type != grantedType {
return annotations.New(&v2.GrantAlreadyRevoked{}), nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What if grantedType == 1 (Basic) and user.Type == 1 ? the guard does not fire in that case IMO

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

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

@mateoHernandez123 mateoHernandez123 force-pushed the mateohernandez/baton-zoom-add-license-management branch from 2691297 to a604a90 Compare June 2, 2026 18:21

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

Comment thread pkg/connector/license.go
"baton-zoom: failed to fetch plan usage; emitting licenses without seat counts",
zap.Error(err),
)
} else if usage != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

no usage specified by license? how it works?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — explained in detail in the header comment of pkg/connector/license.go:1-72. Short version: LicenseProfileTrait was wired in ec305db and each tier gets a different shape depending on what Zoom actually exposes.

Tier Seats surfaced? Why
Basic (type=1) No Uncapped — Zoom doesn't track it.
Licensed (type=2) purchased / consumed from plan_base.hosts / plan_base.usage Paid base-plan seats.
On-Prem (type=3) No Self-hosted via Meeting Connector; the billing API has no visibility into it.

WithLicenseSeats is only added on Licensed when purchased > 0. The plan_base fetch is best-effort — if the billing:read:plan_usage:admin scope is missing the tiers still emit, just without seat numbers. EntitlementIDs are set to license:<id>:assigned so the C1 App Utilization feature (CE-720) can correlate consumed_seats to user grants. Validated against a live Business-Monthly tenant: plan_base.usage matches count(User.type == 2) on every state transition.

Comment thread pkg/connector/license.go Outdated
// and emits one grant per user whose User.type matches the license tier
// being synced. The same status-stack pagination pattern used by userResourceType
// is used here to avoid an extra GET /v2/users/{id} per user.
func (l *licenseResourceType) Grants(ctx context.Context, res *v2.Resource, opts resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we emit license grants from users?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in ec305db

Comment thread pkg/connector/license.go Outdated
logger := ctxzap.Extract(ctx)

if principal.Id.ResourceType != resourceTypeUser.Id {
logger.Warn(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we don't need this warn log here. let's keep connectors clean from logs that are not giving us extra information

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in ec305db

@mateoHernandez123 mateoHernandez123 force-pushed the mateohernandez/baton-zoom-add-license-management branch from 480f70f to a604a90 Compare June 3, 2026 19:22

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

@mateoHernandez123 mateoHernandez123 marked this pull request as draft June 3, 2026 21:16

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

@mateoHernandez123 mateoHernandez123 force-pushed the mateohernandez/baton-zoom-add-license-management branch from ec305db to e2da258 Compare June 4, 2026 17:50
@mateoHernandez123 mateoHernandez123 force-pushed the mateohernandez/baton-zoom-add-license-management branch from e2da258 to 9e7bb21 Compare June 4, 2026 17:50

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

@mateoHernandez123 mateoHernandez123 marked this pull request as ready for review June 4, 2026 17:56
@mateoHernandez123 mateoHernandez123 requested a review from ggreer June 4, 2026 17:57
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.

5 participants