From bf6b2ea624ae543eb0a657f69da7a97a9546ef27 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 17 Aug 2026 19:48:10 +0000 Subject: [PATCH 1/5] feat: expose Temporal Cloud user groups - New group resource type (TRAIT_GROUP) with member entitlement - Groups sync via GetUserGroups; membership via GetUserGroupMembers - Expandable grants for group assignments to account roles (UpdateUserGroup provisioning) and namespace permissions (SetUserGroupNamespaceAccess) - Multi-phase pagination for Grants (users then groups phase) - SCIM/Google group membership marked immutable (IdP-managed) - Graceful PermissionDenied handling for non-admin API keys - capabilities.json and README updated Co-authored-by: c1-squire-dev[bot] --- README.md | 1 + baton_capabilities.json | 14 ++ pkg/connector/account_roles.go | 244 ++++++++++++++++++++-- pkg/connector/connector.go | 1 + pkg/connector/groups.go | 356 ++++++++++++++++++++++++++++++++ pkg/connector/helpers.go | 120 +++++++++++ pkg/connector/namespaces.go | 227 ++++++++++++++++++-- pkg/connector/resource_types.go | 6 + 8 files changed, 940 insertions(+), 29 deletions(-) create mode 100644 pkg/connector/groups.go diff --git a/README.md b/README.md index 975b62e27..aabebf5fa 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ baton resources `baton-temporalcloud` will pull down information about the following Temporal Cloud resources: - Namespaces - Users +- User Groups - Account Roles # Contributing, Support and Issues diff --git a/baton_capabilities.json b/baton_capabilities.json index de0ec8d76..f89ed9c69 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -15,6 +15,20 @@ ], "permissions": {} }, + { + "resourceType": { + "id": "group", + "displayName": "User Group", + "traits": [ + "TRAIT_GROUP" + ] + }, + "capabilities": [ + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" + ], + "permissions": {} + }, { "resourceType": { "id": "namespace", diff --git a/pkg/connector/account_roles.go b/pkg/connector/account_roles.go index 476b4c3a5..e7f18fcea 100644 --- a/pkg/connector/account_roles.go +++ b/pkg/connector/account_roles.go @@ -15,6 +15,8 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" identityv1 "go.temporal.io/cloud-sdk/api/identity/v1" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" cloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" @@ -24,10 +26,11 @@ import ( const ( AccountPermissionAssignmentMaxWaitDuration = 10 * time.Minute -) -const ( roleMemberEntitlement = "member" + + accountRolePhaseUsers = "account-role-grants:users" + accountRolePhaseGroups = "account-role-grants:groups" ) var accountRoles = []identityv1.AccountAccess_Role{ @@ -82,7 +85,7 @@ func (o *accountRoleBuilder) Entitlements(ctx context.Context, r *v2.Resource, _ } member := entitlement.NewAssignmentEntitlement(r, roleMemberEntitlement, - entitlement.WithGrantableTo(userResourceType), + entitlement.WithGrantableTo(userResourceType, groupResourceType), entitlement.WithDescription(fmt.Sprintf("Has the %s role in Temporal Cloud", r.GetDisplayName())), entitlement.WithDisplayName(fmt.Sprintf("%s Role Member", r.GetDisplayName())), entitlement.WithAnnotation(annos...)) @@ -102,10 +105,33 @@ func (o *accountRoleBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs } if bag.Current() == nil { bag.Push(pagination.PageState{ - ResourceTypeID: r.Id.ResourceType, + ResourceTypeID: accountRolePhaseUsers, + ResourceID: r.Id.Resource, + }) + bag.Push(pagination.PageState{ + ResourceTypeID: accountRolePhaseGroups, ResourceID: r.Id.Resource, }) } + + var rv []*v2.Grant + var nextPageToken string + switch bag.ResourceTypeID() { + case accountRolePhaseUsers: + rv, nextPageToken, err = o.listUserAccountRoleGrants(ctx, r, accountID, bag) + case accountRolePhaseGroups: + rv, nextPageToken, err = o.listGroupAccountRoleGrants(ctx, r, accountID, bag) + default: + return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected account role grants pagination phase %q", bag.ResourceTypeID()) + } + if err != nil { + return nil, nil, err + } + + return paginateGrants(rv, bag, nextPageToken) +} + +func (o *accountRoleBuilder) listUserAccountRoleGrants(ctx context.Context, r *v2.Resource, accountID string, bag *pagination.Bag) ([]*v2.Grant, string, error) { req := &cloudservicev1.GetUsersRequest{} if bag.PageToken() != "" { req.PageToken = bag.PageToken() @@ -113,21 +139,53 @@ func (o *accountRoleBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs resp, err := o.client.GetUsers(ctx, req) if err != nil { - return nil, nil, err + return nil, "", err } - var rv []*v2.Grant + rv := make([]*v2.Grant, 0, len(resp.GetUsers())) for _, user := range resp.GetUsers() { if user.GetSpec().GetAccess().GetAccountAccess().GetRole() != AccountAccessRoleFromID(r.Id.Resource, accountID) { continue } grantResource, err := createAccountRoleGrant(user, r, accountID) if err != nil { - return nil, nil, err + return nil, "", err } rv = append(rv, grantResource) } - return paginate(rv, bag, resp.GetNextPageToken()) + return rv, resp.GetNextPageToken(), nil +} + +func (o *accountRoleBuilder) listGroupAccountRoleGrants(ctx context.Context, r *v2.Resource, accountID string, bag *pagination.Bag) ([]*v2.Grant, string, error) { + l := ctxzap.Extract(ctx) + + req := &cloudservicev1.GetUserGroupsRequest{} + if bag.PageToken() != "" { + req.PageToken = bag.PageToken() + } + + resp, err := o.client.GetUserGroups(ctx, req) + if err != nil { + if status.Code(err) == codes.PermissionDenied { + l.Warn("baton-temporalcloud: API key cannot list user groups; skipping group account-role grants", zap.String("role_id", r.GetId().GetResource())) + return nil, "", nil + } + return nil, "", fmt.Errorf("baton-temporalcloud: failed to list user groups: %w", err) + } + + role := AccountAccessRoleFromID(r.Id.Resource, accountID) + rv := make([]*v2.Grant, 0, len(resp.GetGroups())) + for _, group := range resp.GetGroups() { + if group.GetSpec().GetAccess().GetAccountAccess().GetRole() != role { + continue + } + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, "", err + } + rv = append(rv, newGroupAccountRoleGrant(groupResource, r, accountID)) + } + return rv, resp.GetNextPageToken(), nil } func (o *accountRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { @@ -136,6 +194,14 @@ func (o *accountRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, return nil, nil, err } + if principal.GetId().GetResourceType() == groupResourceType.Id { + return o.grantAccountRoleToGroup(ctx, principal, e, accountID) + } + + return o.grantAccountRoleToUser(ctx, principal, e, accountID) +} + +func (o *accountRoleBuilder) grantAccountRoleToUser(ctx context.Context, principal *v2.Resource, e *v2.Entitlement, accountID string) ([]*v2.Grant, annotations.Annotations, error) { entitlementID := e.GetId() userID := principal.GetId().GetResource() userType := principal.GetId().GetResourceType() @@ -214,6 +280,80 @@ func (o *accountRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, return []*v2.Grant{g}, annos, nil } +func (o *accountRoleBuilder) grantAccountRoleToGroup(ctx context.Context, principal *v2.Resource, e *v2.Entitlement, accountID string) ([]*v2.Grant, annotations.Annotations, error) { + groupID := principal.GetId().GetResource() + accountRole := e.GetResource() + accountRoleID := accountRole.GetId().GetResource() + + newRole := AccountAccessRoleFromID(accountRoleID, accountID) + if newRole == identityv1.AccountAccess_ROLE_UNSPECIFIED { + return nil, nil, fmt.Errorf("baton-temporalcloud: invalid account role %s", strings.TrimPrefix(accountRoleID, accountID+"-")) + } + if slices.Contains(immutableAccountRoles, newRole) { + return nil, nil, fmt.Errorf("baton-temporalcloud: role %s is immutable and cannot be granted to a group", accountRoleDisplayName(newRole)) + } + + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + group := groupResp.GetGroup() + spec := group.GetSpec() + + currentRole := spec.GetAccess().GetAccountAccess().GetRole() + if slices.Contains(immutableAccountRoles, currentRole) { + zap.L().Info("baton-temporalcloud: group has immutable role, skipping grant", zap.String("group_id", groupID)) + return nil, nil, nil + } + if currentRole == newRole { + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil + } + + newSpec := &identityv1.UserGroupSpec{ + DisplayName: spec.GetDisplayName(), + Access: &identityv1.Access{ + AccountAccess: &identityv1.AccountAccess{Role: newRole}, + NamespaceAccesses: spec.GetAccess().GetNamespaceAccesses(), + }, + GroupType: spec.GetGroupType(), + } + + req := &cloudservicev1.UpdateUserGroupRequest{GroupId: groupID, Spec: newSpec, ResourceVersion: group.GetResourceVersion()} + resp, err := o.client.UpdateUserGroup(ctx, req) + if err != nil { + if strings.Contains(err.Error(), "nothing to change") { + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil + } + return nil, nil, fmt.Errorf("baton-temporalcloud: could not grant entitlement to group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("group_id", groupID), + zap.String("entitlement_resource_id", accountRoleID), + ) + waitCtx, cancel := context.WithTimeout(ctx, AccountPermissionAssignmentMaxWaitDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: group account role assignment creation failed: %w", err) + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, nil, err + } + + g := newGroupAccountRoleGrant(groupResource, accountRole, accountID) + return []*v2.Grant{g}, annos, nil +} + func (o *accountRoleBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { accountID, err := o.client.GetAccountID(ctx) if err != nil { @@ -221,19 +361,30 @@ func (o *accountRoleBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotatio } e := g.GetEntitlement() - principal := g.GetPrincipal() - entitlementID := e.GetId() - userID := principal.GetId().GetResource() - userType := principal.GetId().GetResourceType() accountRole := e.GetResource() accountRoleID := accountRole.GetId().GetResource() - accountRoleType := accountRole.GetId().GetResourceType() ar := AccountAccessRoleFromID(accountRoleID, accountID) if slices.Contains(immutableAccountRoles, ar) { return nil, fmt.Errorf("baton-temporalcloud: role %s is immutable and cannot be revoked", accountRoleDisplayName(ar)) } + principal := g.GetPrincipal() + if principal.GetId().GetResourceType() == groupResourceType.Id { + return o.revokeAccountRoleFromGroup(ctx, principal, accountRoleID, ar) + } + + return o.revokeAccountRoleFromUser(ctx, g, accountRoleID, ar, accountID) +} + +func (o *accountRoleBuilder) revokeAccountRoleFromUser(ctx context.Context, g *v2.Grant, accountRoleID string, ar identityv1.AccountAccess_Role, accountID string) (annotations.Annotations, error) { + e := g.GetEntitlement() + principal := g.GetPrincipal() + entitlementID := e.GetId() + userID := principal.GetId().GetResource() + userType := principal.GetId().GetResourceType() + accountRoleType := e.GetResource().GetId().GetResourceType() + userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) if err != nil { return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve user: %w", err) @@ -300,6 +451,73 @@ func (o *accountRoleBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotatio return annos, nil } +func (o *accountRoleBuilder) revokeAccountRoleFromGroup(ctx context.Context, principal *v2.Resource, accountRoleID string, ar identityv1.AccountAccess_Role) (annotations.Annotations, error) { + groupID := principal.GetId().GetResource() + groupType := principal.GetId().GetResourceType() + + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + group := groupResp.GetGroup() + spec := group.GetSpec() + + if spec.GetAccess().GetAccountAccess().GetRole() != ar { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + var downgradedRole *identityv1.AccountAccess + switch ar { + case identityv1.AccountAccess_ROLE_ADMIN: + downgradedRole = &identityv1.AccountAccess{Role: identityv1.AccountAccess_ROLE_DEVELOPER} + case identityv1.AccountAccess_ROLE_DEVELOPER: + downgradedRole = &identityv1.AccountAccess{Role: identityv1.AccountAccess_ROLE_READ} + case identityv1.AccountAccess_ROLE_READ: + downgradedRole = nil + default: + return nil, fmt.Errorf("baton-temporalcloud: invalid account role %s", ar) + } + + newSpec := &identityv1.UserGroupSpec{ + DisplayName: spec.GetDisplayName(), + Access: &identityv1.Access{ + AccountAccess: downgradedRole, + NamespaceAccesses: spec.GetAccess().GetNamespaceAccesses(), + }, + GroupType: spec.GetGroupType(), + } + + req := &cloudservicev1.UpdateUserGroupRequest{GroupId: groupID, Spec: newSpec, ResourceVersion: group.GetResourceVersion()} + resp, err := o.client.UpdateUserGroup(ctx, req) + if err != nil { + if strings.Contains(err.Error(), "nothing to change") { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + return nil, fmt.Errorf("baton-temporalcloud: could not revoke entitlement for group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("group_id", groupID), + zap.String("group_type", groupType), + zap.String("entitlement_resource_id", accountRoleID), + ) + waitCtx, cancel := context.WithTimeout(ctx, AccountPermissionAssignmentMaxWaitDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: group account role removal failed: %w", err) + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return annos, nil +} + func newAccountBuilder(client *client.Client) *accountRoleBuilder { return &accountRoleBuilder{ client: client, diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f543033c6..23d19edc2 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -30,6 +30,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso newUserBuilder(d.cloudServiceClient, d.accountCreationSettings), newServiceAccountBuilder(d.cloudServiceClient), newNamespaceBuilder(d.cloudServiceClient), + newGroupBuilder(d.cloudServiceClient), newAccountBuilder(d.cloudServiceClient), } } diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go new file mode 100644 index 000000000..5406e4514 --- /dev/null +++ b/pkg/connector/groups.go @@ -0,0 +1,356 @@ +package connector + +import ( + "context" + "fmt" + "strings" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + cloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" + identityv1 "go.temporal.io/cloud-sdk/api/identity/v1" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const ( + GroupMembershipMaxWaitDuration = 10 * time.Minute +) + +var _ connectorbuilder.ResourceProvisionerV2 = (*groupBuilder)(nil) + +type groupBuilder struct { + client cloudservicev1.CloudServiceClient +} + +func (o *groupBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return groupResourceType +} + +func (o *groupBuilder) List(ctx context.Context, _ *v2.ResourceId, opts rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + bag := &pagination.Bag{} + err := bag.Unmarshal(opts.PageToken.Token) + if err != nil { + return nil, nil, err + } + + if bag.Current() == nil { + bag.Push(pagination.PageState{ + ResourceTypeID: groupResourceType.Id, + }) + } + + req := &cloudservicev1.GetUserGroupsRequest{} + if bag.PageToken() != "" { + req.PageToken = bag.PageToken() + } + + resp, err := o.client.GetUserGroups(ctx, req) + if err != nil { + if isPermissionDeniedWithOperatorWarning(err) { + ctxzap.Extract(ctx).Warn("baton-temporalcloud: cannot list user groups with the current API key, skipping groups", zap.Error(err)) + return nil, nil, nil + } + return nil, nil, fmt.Errorf("baton-temporalcloud: failed to list user groups: %w", err) + } + + rv := make([]*v2.Resource, 0, len(resp.GetGroups())) + for _, group := range resp.GetGroups() { + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, nil, err + } + rv = append(rv, groupResource) + } + + return paginate(rv, bag, resp.GetNextPageToken()) +} + +// Entitlements emits the group member entitlement. Membership of SCIM and +// Google groups is owned by the external identity provider, so those +// entitlements are marked immutable. +func (o *groupBuilder) Entitlements(_ context.Context, r *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + grantsRails := []entitlement.EntitlementOption{ + entitlement.WithGrantableTo(userResourceType), + entitlement.WithDisplayName(fmt.Sprintf("%s Group Member", r.GetDisplayName())), + entitlement.WithDescription(fmt.Sprintf("Member of the %s user group in Temporal Cloud", r.GetDisplayName())), + } + + if isImmutablyProvisionedGroup(r) { + grantsRails = append(grantsRails, entitlement.WithAnnotation(&v2.EntitlementImmutable{})) + } + + member := entitlement.NewAssignmentEntitlement(r, groupMemberEntitlement, grantsRails...) + return []*v2.Entitlement{member}, nil, nil +} + +func (o *groupBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + bag := &pagination.Bag{} + err := bag.Unmarshal(opts.PageToken.Token) + if err != nil { + return nil, nil, err + } + + if bag.Current() == nil { + bag.Push(pagination.PageState{ + ResourceTypeID: r.GetId().GetResourceType(), + ResourceID: r.GetId().GetResource(), + }) + } + + req := &cloudservicev1.GetUserGroupMembersRequest{ + GroupId: r.GetId().GetResource(), + } + if bag.PageToken() != "" { + req.PageToken = bag.PageToken() + } + + resp, err := o.client.GetUserGroupMembers(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: failed to list user group members: %w", err) + } + + rv := make([]*v2.Grant, 0, len(resp.GetMembers())) + for _, member := range resp.GetMembers() { + userID := member.GetMemberId().GetUserId() + if userID == "" { + // Group members are users today; future member types surface no + // user id and are not representable as grants. + continue + } + + ur, err := fetchUserResource(ctx, o.client, userID) + if err != nil { + if status.Code(err) == codes.NotFound { + ctxzap.Extract(ctx).Warn("baton-temporalcloud: skipping group member without matching user", + zap.String("group_id", r.GetId().GetResource()), + zap.String("user_id", userID)) + continue + } + return nil, nil, err + } + + g, err := createUserGroupMemberGrant(r, ur, isImmutablyProvisionedGroup(r)) + if err != nil { + return nil, nil, err + } + rv = append(rv, g) + } + + return paginate(rv, bag, resp.GetNextPageToken()) +} + +// Grant adds a user to a Cloud user group. Membership of SCIM and Google +// groups is managed by the external identity provider and cannot be granted +// through the Temporal Cloud API. +func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + if e.GetSlug() != groupMemberEntitlement { + return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected entitlement slug %q", e.GetSlug()) + } + + groupID := e.GetResource().GetId().GetResource() + userID := principal.GetId().GetResource() + + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + group := groupResp.GetGroup() + if kind := groupKindFromSpec(group.GetSpec()); kind != groupKindCloud { + return nil, nil, fmt.Errorf("baton-temporalcloud: %s groups are managed by the external identity provider and cannot be provisioned", kind) + } + + userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve user: %w", err) + } + + resp, err := o.client.AddUserGroupMember(ctx, &cloudservicev1.AddUserGroupMemberRequest{ + GroupId: groupID, + MemberId: &identityv1.UserGroupMemberId{ + MemberType: &identityv1.UserGroupMemberId_UserId{ + UserId: userID, + }, + }, + }) + if err != nil { + if isAlreadyExistsError(err) { + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil + } + return nil, nil, fmt.Errorf("baton-temporalcloud: could not add user to group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("principal_id", userID), + zap.String("group_id", groupID), + ) + waitCtx, cancel := context.WithTimeout(ctx, GroupMembershipMaxWaitDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: group membership creation failed: %w", err) + } + + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, nil, err + } + + user, err := protoUserToResource(userResp.GetUser()) + if err != nil { + return nil, nil, err + } + + g, err := createUserGroupMemberGrant(groupResource, user, false) + if err != nil { + return nil, nil, err + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return []*v2.Grant{g}, annos, nil +} + +// Revoke removes a user from a Cloud user group. +func (o *groupBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { + e := g.GetEntitlement() + if e.GetSlug() != groupMemberEntitlement { + return nil, fmt.Errorf("baton-temporalcloud: unexpected entitlement slug %q", e.GetSlug()) + } + + groupID := e.GetResource().GetId().GetResource() + userID := g.GetPrincipal().GetId().GetResource() + + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + if kind := groupKindFromSpec(groupResp.GetGroup().GetSpec()); kind != groupKindCloud { + return nil, fmt.Errorf("baton-temporalcloud: %s groups are managed by the external identity provider and cannot be provisioned", kind) + } + + resp, err := o.client.RemoveUserGroupMember(ctx, &cloudservicev1.RemoveUserGroupMemberRequest{ + GroupId: groupID, + MemberId: &identityv1.UserGroupMemberId{ + MemberType: &identityv1.UserGroupMemberId_UserId{ + UserId: userID, + }, + }, + }) + if err != nil { + if isNotFoundError(err) { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + return nil, fmt.Errorf("baton-temporalcloud: could not remove user from group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("principal_id", userID), + zap.String("group_id", groupID), + ) + waitCtx, cancel := context.WithTimeout(ctx, GroupMembershipMaxWaitDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: group membership deletion failed: %w", err) + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return annos, nil +} + +func newGroupBuilder(client cloudservicev1.CloudServiceClient) *groupBuilder { + return &groupBuilder{client: client} +} + +// fetchUserResource loads a user from Temporal Cloud and builds its resource. +// The caller is responsible for handling codes.NotFound. +func fetchUserResource(ctx context.Context, client cloudservicev1.CloudServiceClient, userID string) (*v2.Resource, error) { + userResp, err := client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) + if err != nil { + return nil, err + } + return protoUserToResource(userResp.GetUser()) +} + +// createUserGroupMemberGrant builds a membership grant for a user in a group, +// optionally marked immutable for groups whose membership is owned by an +// external identity provider. +func createUserGroupMemberGrant(group *v2.Resource, user *v2.Resource, immutable bool) (*v2.Grant, error) { + annos := []proto.Message{ + &v2.V1Identifier{ + Id: grantID(membershipEntitlementID(group.GetId().GetResource()), user.GetId().GetResource()), + }, + } + if immutable { + annos = append(annos, &v2.GrantImmutable{}) + } + + g := grant.NewGrant(group, groupMemberEntitlement, user.GetId(), grant.WithAnnotation(annos...)) + g.Principal = user + return g, nil +} + +// groupKindFromResource reads the group kind recorded in the group's profile +// during List. Returns "" when profile data is unavailable or does not carry +// the group kind. +func groupKindFromResource(r *v2.Resource) string { + profile := rs.GetProfile(r) + if profile == nil { + return "" + } + return profile.GetFields()[groupKindProfileKey].GetStringValue() +} + +// isImmutablyProvisionedGroup reports whether a group's membership is owned by +// an external identity provider (SCIM or Google groups). Groups whose kind is +// unknown are treated as provisionable so entitlements remain available. +func isImmutablyProvisionedGroup(r *v2.Resource) bool { + kind := groupKindFromResource(r) + return kind == groupKindScim || kind == groupKindGoogle +} + +// isPermissionDeniedWithOperatorWarning reports whether a API call couldn't be +// made due to missing permissions. The API key may lack the account role +// required to manage user groups (Owner or Global Admin); in that case the +// rest of a sync should not fail. +func isPermissionDeniedWithOperatorWarning(err error) bool { + return status.Code(err) == codes.PermissionDenied +} + +// isAlreadyExistsError reports whether the API call returned an already-exists +// error, which is treated as idempotent success. +func isAlreadyExistsError(err error) bool { + if status.Code(err) == codes.AlreadyExists { + return true + } + return strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already a member") +} + +// isNotFoundError reports whether the API call returned a not-found error. +func isNotFoundError(err error) bool { + if status.Code(err) == codes.NotFound { + return true + } + return strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not a member") +} diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 21e44be14..2588b8565 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -14,6 +14,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/fatih/camelcase" @@ -88,6 +89,45 @@ func protoAccountRoleToResource(proto identityv1.AccountAccess_Role, accountID s return role, nil } +// protoUserGroupToResource builds a group resource. The group kind is recorded +// in the group profile because it determines whether membership is manageable +// through Temporal Cloud's member APIs (Cloud groups) or owned by an external +// identity provider (SCIM and Google groups). +func protoUserGroupToResource(group *identityv1.UserGroup) (*v2.Resource, error) { + spec := group.GetSpec() + + profile := map[string]interface{}{} + switch kind := groupKindFromSpec(spec); kind { + case groupKindGoogle: + profile["group_kind"] = kind + profile["google_email"] = spec.GetGoogleGroup().GetEmailAddress() + case groupKindScim: + profile["group_kind"] = kind + profile["scim_idp_id"] = spec.GetScimGroup().GetIdpId() + default: + profile["group_kind"] = kind + } + + annos := &v2.V1Identifier{ + Id: fmt.Sprintf("group:%s", group.GetId()), + } + + displayName := spec.GetDisplayName() + if displayName == "" { + displayName = group.GetId() + } + + groupResource, err := rs.NewGroupResource(displayName, groupResourceType, group.GetId(), nil, + rs.WithResourceCreatedAt(group.GetCreatedTime().AsTime()), + rs.WithAnnotation(annos), + rs.WithResourceProfile(profile), + ) + if err != nil { + return nil, err + } + return groupResource, nil +} + func createNamespaceGrant(user *identityv1.User, namespace *v2.Resource, permission identityv1.NamespaceAccess_Permission) (*v2.Grant, error) { perm := namespacePermissionName(permission) ur, err := protoUserToResource(user) @@ -128,6 +168,44 @@ func createAccountRoleGrant(user *identityv1.User, ar *v2.Resource, accountID st return g, nil } +// newGroupAccountRoleGrant builds a grant of an account role to a user group. +// The grant is expandable over the group's member entitlement so group members +// transitively receive the role. +func newGroupAccountRoleGrant(groupResource *v2.Resource, ar *v2.Resource, accountID string) *v2.Grant { + annos := []proto.Message{ + &v2.V1Identifier{ + Id: grantID(membershipEntitlementID(ar.GetId().GetResource()), groupResource.GetId().GetResource()), + }, + &v2.GrantExpandable{ + EntitlementIds: []string{entitlement.NewEntitlementID(groupResource, groupMemberEntitlement)}, + }, + } + + accountRole := AccountAccessRoleFromID(ar.GetId().GetResource(), accountID) + if slices.Contains(immutableAccountRoles, accountRole) { + annos = append(annos, &v2.GrantImmutable{}) + } + + return grant.NewGrant(ar, roleMemberEntitlement, groupResource.GetId(), grant.WithAnnotation(annos...)) +} + +// newGroupNamespaceGrant builds a grant of a namespace permission to a user +// group. The grant is expandable over the group's member entitlement so group +// members transitively receive the permission. +func newGroupNamespaceGrant(groupResource *v2.Resource, namespace *v2.Resource, permission identityv1.NamespaceAccess_Permission) *v2.Grant { + perm := namespacePermissionName(permission) + annos := []proto.Message{ + &v2.V1Identifier{ + Id: grantID(namespaceEntitlementID(namespace.GetId().GetResource(), perm), groupResource.GetId().GetResource()), + }, + &v2.GrantExpandable{ + EntitlementIds: []string{entitlement.NewEntitlementID(groupResource, groupMemberEntitlement)}, + }, + } + + return grant.NewGrant(namespace, perm, groupResource.GetId(), grant.WithAnnotation(annos...)) +} + func awaitAsyncOperation(ctx context.Context, l *zap.Logger, client cloudservicev1.CloudServiceClient, requestID string, retryDelay time.Duration) error { complete, err := checkAsyncOperation(ctx, client, requestID) if err != nil { @@ -185,10 +263,33 @@ func paginate[T any](rv T, bag *pagination.Bag, pageToken string) (T, *rs.SyncOp return rv, &rs.SyncOpResults{NextPageToken: token}, nil } +// paginateGrants advances the pagination bag used by multi-phase grants syncs. +// When the current API page is exhausted it moves to the next phase; when no +// phases remain it returns no results to end the sync. +func paginateGrants(rv []*v2.Grant, bag *pagination.Bag, pageToken string) ([]*v2.Grant, *rs.SyncOpResults, error) { + if pageToken != "" { + if err := bag.Next(pageToken); err != nil { + return nil, nil, err + } + } else { + bag.Pop() + } + + token, err := bag.Marshal() + if err != nil { + return nil, nil, err + } + if token == "" { + return rv, nil, nil + } + return rv, &rs.SyncOpResults{NextPageToken: token}, nil +} + const ( membershipEntitlementIDTemplate = "membership:%s" namespaceEntitlementIDTemplate = "namespace:%s:%s" grantIDTemplate = "grant:%s:%s" + groupMemberEntitlement = "member" ) func grantID(entitlementID string, userID string) string { @@ -203,6 +304,25 @@ func namespaceEntitlementID(resourceID string, role string) string { return fmt.Sprintf(namespaceEntitlementIDTemplate, resourceID, role) } +const ( + groupKindCloud = "cloud" + groupKindGoogle = "google" + groupKindScim = "scim" + groupKindProfileKey = "group_kind" +) + +func groupKindFromSpec(spec *identityv1.UserGroupSpec) string { + switch { + case spec.GetCloudGroup() != nil: + return groupKindCloud + case spec.GetGoogleGroup() != nil: + return groupKindGoogle + case spec.GetScimGroup() != nil: + return groupKindScim + } + return "" +} + func fromStringToEnum(prefix string, in string) string { in = strings.Map(func(r rune) rune { if r == '-' { diff --git a/pkg/connector/namespaces.go b/pkg/connector/namespaces.go index 9f1c14862..40055715c 100644 --- a/pkg/connector/namespaces.go +++ b/pkg/connector/namespaces.go @@ -15,10 +15,15 @@ import ( cloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" identityv1 "go.temporal.io/cloud-sdk/api/identity/v1" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) const ( NamespacePermissionAssignmentMaxDuration = 10 * time.Minute + + namespacePhaseUsers = "namespace-grants:users" + namespacePhaseGroups = "namespace-grants:groups" ) var namespaceAccessLevels = []identityv1.NamespaceAccess_Permission{ @@ -81,7 +86,7 @@ func (o *namespaceBuilder) Entitlements(_ context.Context, resource *v2.Resource entitlement.WithDisplayName(namespacePermissionDisplayName(level, resource.GetDisplayName())), entitlement.WithDescription(fmt.Sprintf("Access to %s namespace in Temporal Cloud", resource.GetDisplayName())), entitlement.WithAnnotation(annos), - entitlement.WithGrantableTo(userResourceType), + entitlement.WithGrantableTo(userResourceType, groupResourceType), ) rv = append(rv, e) } @@ -97,11 +102,33 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op } if bag.Current() == nil { bag.Push(pagination.PageState{ - ResourceTypeID: resource.GetId().GetResourceType(), + ResourceTypeID: namespacePhaseUsers, + ResourceID: resource.GetId().GetResource(), + }) + bag.Push(pagination.PageState{ + ResourceTypeID: namespacePhaseGroups, ResourceID: resource.GetId().GetResource(), }) } + var rv []*v2.Grant + var nextPageToken string + switch bag.ResourceTypeID() { + case namespacePhaseUsers: + rv, nextPageToken, err = o.listUserNamespaceGrants(ctx, resource, bag) + case namespacePhaseGroups: + rv, nextPageToken, err = o.listGroupNamespaceGrants(ctx, resource, bag) + default: + return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected namespace grants pagination phase %q", bag.ResourceTypeID()) + } + if err != nil { + return nil, nil, err + } + + return paginateGrants(rv, bag, nextPageToken) +} + +func (o *namespaceBuilder) listUserNamespaceGrants(ctx context.Context, resource *v2.Resource, bag *pagination.Bag) ([]*v2.Grant, string, error) { req := &cloudservicev1.GetUsersRequest{Namespace: resource.GetDisplayName()} if bag.PageToken() != "" { req.PageToken = bag.PageToken() @@ -109,7 +136,7 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op resp, err := o.client.GetUsers(ctx, req) if err != nil { - return nil, nil, err + return nil, "", err } var rv []*v2.Grant @@ -121,18 +148,50 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op g, err := createNamespaceGrant(user, resource, permission.GetPermission()) if err != nil { - return nil, nil, err + return nil, "", err } rv = append(rv, g) } - return paginate(rv, bag, resp.GetNextPageToken()) + return rv, resp.GetNextPageToken(), nil +} + +func (o *namespaceBuilder) listGroupNamespaceGrants(ctx context.Context, resource *v2.Resource, bag *pagination.Bag) ([]*v2.Grant, string, error) { + l := ctxzap.Extract(ctx) + nsID := resource.GetId().GetResource() + + req := &cloudservicev1.GetUserGroupsRequest{Namespace: resource.GetDisplayName()} + if bag.PageToken() != "" { + req.PageToken = bag.PageToken() + } + + resp, err := o.client.GetUserGroups(ctx, req) + if err != nil { + if status.Code(err) == codes.PermissionDenied { + l.Warn("baton-temporalcloud: API key cannot list user groups; skipping group namespace grants", zap.String("namespace_id", nsID)) + return nil, "", nil + } + return nil, "", fmt.Errorf("baton-temporalcloud: failed to list user groups: %w", err) + } + + rv := make([]*v2.Grant, 0, len(resp.GetGroups())) + for _, group := range resp.GetGroups() { + nsAccess, ok := group.GetSpec().GetAccess().GetNamespaceAccesses()[nsID] + if !ok { + continue + } + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, "", err + } + rv = append(rv, newGroupNamespaceGrant(groupResource, resource, nsAccess.GetPermission())) + } + + return rv, resp.GetNextPageToken(), nil } func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { entitlementID := e.GetId() - userID := principal.GetId().GetResource() - userType := principal.GetId().GetResourceType() namespace := e.GetResource() namespaceID := namespace.GetId().GetResource() namespaceType := namespace.GetId().GetResourceType() @@ -149,6 +208,24 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e return nil, nil, fmt.Errorf("baton-temporalcloud: invalid namespace permission %s", nsRole) } + principalID := principal.GetId().GetResource() + principalType := principal.GetId().GetResourceType() + + if principalType == groupResourceType.Id { + return o.grantNamespacePermissionToGroup(ctx, principalID, namespace, namespaceRole, namespaceType) + } + if principalType == userResourceType.Id { + return o.grantNamespacePermissionToUser(ctx, principalID, principalType, namespace, namespaceID, namespaceRole, namespaceType, entitlementID) + } + return nil, nil, fmt.Errorf("baton-temporalcloud: unsupported principal type %s", principalType) +} + +func (o *namespaceBuilder) grantNamespacePermissionToUser( + ctx context.Context, userID string, userType string, + namespace *v2.Resource, namespaceID string, + namespaceRole identityv1.NamespaceAccess_Permission, + namespaceType string, entitlementID string, +) ([]*v2.Grant, annotations.Annotations, error) { userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) if err != nil { return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve user: %w", err) @@ -164,8 +241,7 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e } else { existing, ok := ns[namespaceID] if ok && existing.GetPermission() == namespaceRole { - annos := annotations.New(&v2.GrantAlreadyExists{}) - return nil, annos, nil + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil } ns[namespaceID] = perm } @@ -177,8 +253,7 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e if strings.Contains(err.Error(), "nothing to change") { return nil, annotations.New(&v2.GrantAlreadyExists{}), nil } - - return nil, nil, fmt.Errorf("baton-temporalcloud: could not grant entitlement to user: %w", err) + return nil, nil, fmt.Errorf("baton-temporalcloud: could not grant namespace permission to user: %w", err) } retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() @@ -195,7 +270,7 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e defer cancel() err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) if err != nil { - return nil, nil, fmt.Errorf("baton-temporalcloud: namespace assignment creation failed: %w", err) + return nil, nil, fmt.Errorf("baton-temporalcloud: namespace permission grant to user failed: %w", err) } g, err := createNamespaceGrant(user, namespace, namespaceRole) @@ -209,14 +284,87 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e return []*v2.Grant{g}, annos, nil } +func (o *namespaceBuilder) grantNamespacePermissionToGroup( + ctx context.Context, groupID string, + namespace *v2.Resource, namespaceRole identityv1.NamespaceAccess_Permission, + namespaceType string, +) ([]*v2.Grant, annotations.Annotations, error) { + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + group := groupResp.GetGroup() + namespaceID := namespace.GetId().GetResource() + existing := group.GetSpec().GetAccess().GetNamespaceAccesses()[namespaceID] + if existing != nil && existing.GetPermission() == namespaceRole { + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil + } + + resp, err := o.client.SetUserGroupNamespaceAccess(ctx, &cloudservicev1.SetUserGroupNamespaceAccessRequest{ + Namespace: namespaceID, + GroupId: groupID, + Access: &identityv1.NamespaceAccess{Permission: namespaceRole}, + ResourceVersion: group.GetResourceVersion(), + }) + if err != nil { + if strings.Contains(err.Error(), "nothing to change") { + return nil, annotations.New(&v2.GrantAlreadyExists{}), nil + } + return nil, nil, fmt.Errorf("baton-temporalcloud: could not grant namespace permission to group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("group_id", groupID), + zap.String("namespace_id", namespaceID), + zap.String("entitlement_resource_type", namespaceType), + ) + waitCtx, cancel := context.WithTimeout(ctx, NamespacePermissionAssignmentMaxDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: namespace permission grant to group failed: %w", err) + } + + groupResource, err := protoUserGroupToResource(group) + if err != nil { + return nil, nil, err + } + + g := newGroupNamespaceGrant(groupResource, namespace, namespaceRole) + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return []*v2.Grant{g}, annos, nil +} + func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { - userID := g.GetPrincipal().GetId().GetResource() - userType := g.GetPrincipal().GetId().GetResourceType() + principal := g.GetPrincipal() + principalID := principal.GetId().GetResource() + principalType := principal.GetId().GetResourceType() entitlementID := g.GetEntitlement().GetId() namespace := g.GetEntitlement().GetResource() namespaceID := namespace.GetId().GetResource() namespaceType := namespace.GetId().GetResourceType() + if principalType == groupResourceType.Id { + return o.revokeNamespaceAccessFromGroup(ctx, g, principalID, namespaceID, namespaceType, entitlementID) + } + + if principalType == userResourceType.Id { + return o.revokeNamespaceAccessFromUser(ctx, g, principalID, principalType, namespaceID, namespaceType, entitlementID) + } + return nil, fmt.Errorf("baton-temporalcloud: unsupported principal type %s", principalType) +} + +func (o *namespaceBuilder) revokeNamespaceAccessFromUser( + ctx context.Context, _ *v2.Grant, userID string, userType string, + namespaceID string, namespaceType string, entitlementID string, +) (annotations.Annotations, error) { userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) if err != nil { return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve user: %w", err) @@ -232,7 +380,7 @@ func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations req := &cloudservicev1.UpdateUserRequest{UserId: userID, Spec: spec, ResourceVersion: user.GetResourceVersion()} resp, err := o.client.UpdateUser(ctx, req) if err != nil { - return nil, fmt.Errorf("baton-temporalcloud: could not revoke grant for user: %w", err) + return nil, fmt.Errorf("baton-temporalcloud: could not revoke namespace permission from user: %w", err) } retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() @@ -249,7 +397,54 @@ func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations defer cancel() err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) if err != nil { - return nil, fmt.Errorf("baton-temporalcloud: namespace assignment deletion failed: %w", err) + return nil, fmt.Errorf("baton-temporalcloud: namespace permission revoke for user failed: %w", err) + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return annos, nil +} + +func (o *namespaceBuilder) revokeNamespaceAccessFromGroup( + ctx context.Context, _ *v2.Grant, groupID string, + namespaceID string, namespaceType string, entitlementID string, +) (annotations.Annotations, error) { + groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) + } + + group := groupResp.GetGroup() + _, ok := group.GetSpec().GetAccess().GetNamespaceAccesses()[namespaceID] + if !ok { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + resp, err := o.client.SetUserGroupNamespaceAccess(ctx, &cloudservicev1.SetUserGroupNamespaceAccessRequest{ + Namespace: namespaceID, + GroupId: groupID, + Access: nil, + ResourceVersion: group.GetResourceVersion(), + }) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: could not revoke namespace permission from group: %w", err) + } + + retryDelay := resp.GetAsyncOperation().GetCheckDuration().AsDuration() + requestID := resp.GetAsyncOperation().GetId() + l := ctxzap.Extract(ctx).With( + zap.String("request_id", requestID), + zap.String("group_id", groupID), + zap.String("entitlement_id", entitlementID), + zap.String("entitlement_resource_id", namespaceID), + zap.String("entitlement_resource_type", namespaceType), + ) + waitCtx, cancel := context.WithTimeout(ctx, NamespacePermissionAssignmentMaxDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: namespace permission revoke for group failed: %w", err) } annos := annotations.New() diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index fef141c85..4e1c651b2 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -22,6 +22,12 @@ var serviceAccountResourceType = &v2.ResourceType{ Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}), } +var groupResourceType = &v2.ResourceType{ + Id: "group", + DisplayName: "User Group", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + var namespaceResourceType = &v2.ResourceType{ Id: "namespace", DisplayName: "Namespace", From fd36f1a9e9d4b89216ca9871059f9f8e6bad96ed Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 17 Aug 2026 19:52:05 +0000 Subject: [PATCH 2/5] fix: use opt_in_required for groups, revert config flag Groups are an opt-in resource type via capabilities.json rather than a config flag. Existing installations won't sync groups until enabled. Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 3 ++- pkg/config/conf.gen.go | 2 +- pkg/config/config.go | 1 + pkg/connector/connector.go | 1 - 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index f89ed9c69..9746bfef3 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -27,7 +27,8 @@ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": {}, + "opt_in_required": true }, { "resourceType": { diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 799873c61..70cd0c17f 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -1,7 +1,7 @@ // Code generated by baton-sdk. DO NOT EDIT!!! package config -import "reflect" +import "reflect" type TemporalCloud struct { ApiKey string `mapstructure:"api-key"` diff --git a/pkg/config/config.go b/pkg/config/config.go index 3761893c1..a97dc1722 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,7 @@ var ( field.WithRequired(false), ) + BaseURLField = field.StringField( "base-url", field.WithDescription("Override the Temporal Cloud API URL (for testing)"), diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 23d19edc2..cb58610ae 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -106,7 +106,6 @@ func New(ctx context.Context, tc *cfg.TemporalCloud, opts *cli.ConnectorOpts) (c } defaultRole = *r } - connector := &Connector{ cloudServiceClient: c, accountCreationSettings: AccountCreationSettings{ From 3e228e7d6c7f366752da4a69dbcc247cf4519144 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 17 Aug 2026 20:00:30 +0000 Subject: [PATCH 3/5] fix: use OptInRequired annotation for groups, regenerate capabilities.json The SDK detects opt_in_required from the annotations on the ResourceType definition, not a top-level json property. Regenerated from binary output. Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 7 ++++++- pkg/connector/resource_types.go | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index 9746bfef3..af6a665a3 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -21,6 +21,11 @@ "displayName": "User Group", "traits": [ "TRAIT_GROUP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" + } ] }, "capabilities": [ @@ -28,7 +33,7 @@ "CAPABILITY_PROVISION" ], "permissions": {}, - "opt_in_required": true + "optInRequired": true }, { "resourceType": { diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 4e1c651b2..be96d9bea 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -26,6 +26,7 @@ var groupResourceType = &v2.ResourceType{ Id: "group", DisplayName: "User Group", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(&v2.OptInRequired{}), } var namespaceResourceType = &v2.ResourceType{ From 4bcc36000e4debfc24659012ca203f93c044bccb Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 17 Aug 2026 20:16:55 +0000 Subject: [PATCH 4/5] fix: address all judge review findings Blocking: - Gate group grant phases on issuer.WillSyncResourceType so group-principal grants are not emitted when the group type is OptInRequired but not enabled. Review additions: - Legacy page state tolerance in Grants default switch cases. - Drop N+1 GetUser per group member; use principal ResourceId only. - Remove substring error fallbacks; rely on gRPC status codes. - Replace zap.L() with ctxzap.Extract(ctx) for context-carrying log. - Return an error when group holds an immutible role (not nil/nil/nil). - Drop unused *v2.Grant param from revokeNamespaceAccessFromUser/Group. - Simplify createUserGroupMemberGrant (no error return). Co-authored-by: c1-squire-dev[bot] \ --- pkg/connector/account_roles.go | 26 +++--- pkg/connector/connector.go | 13 ++- pkg/connector/groups.go | 148 +++++++-------------------------- pkg/connector/namespaces.go | 29 ++++--- 4 files changed, 74 insertions(+), 142 deletions(-) diff --git a/pkg/connector/account_roles.go b/pkg/connector/account_roles.go index e7f18fcea..976ab9062 100644 --- a/pkg/connector/account_roles.go +++ b/pkg/connector/account_roles.go @@ -42,7 +42,8 @@ var accountRoles = []identityv1.AccountAccess_Role{ } type accountRoleBuilder struct { - client *client.Client + client *client.Client + syncGroups bool } func (o *accountRoleBuilder) ResourceType(ctx context.Context) *v2.ResourceType { @@ -108,10 +109,12 @@ func (o *accountRoleBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs ResourceTypeID: accountRolePhaseUsers, ResourceID: r.Id.Resource, }) - bag.Push(pagination.PageState{ - ResourceTypeID: accountRolePhaseGroups, - ResourceID: r.Id.Resource, - }) + if o.syncGroups { + bag.Push(pagination.PageState{ + ResourceTypeID: accountRolePhaseGroups, + ResourceID: r.Id.Resource, + }) + } } var rv []*v2.Grant @@ -122,7 +125,9 @@ func (o *accountRoleBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs case accountRolePhaseGroups: rv, nextPageToken, err = o.listGroupAccountRoleGrants(ctx, r, accountID, bag) default: - return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected account role grants pagination phase %q", bag.ResourceTypeID()) + // Legacy page states from the previous connector version used the + // resource type id as the state marker; treat as the users phase. + rv, nextPageToken, err = o.listUserAccountRoleGrants(ctx, r, accountID, bag) } if err != nil { return nil, nil, err @@ -303,8 +308,8 @@ func (o *accountRoleBuilder) grantAccountRoleToGroup(ctx context.Context, princi currentRole := spec.GetAccess().GetAccountAccess().GetRole() if slices.Contains(immutableAccountRoles, currentRole) { - zap.L().Info("baton-temporalcloud: group has immutable role, skipping grant", zap.String("group_id", groupID)) - return nil, nil, nil + ctxzap.Extract(ctx).Warn("baton-temporalcloud: group has immutable role, skipping grant", zap.String("group_id", groupID)) + return nil, nil, fmt.Errorf("baton-temporalcloud: cannot grant role to group %s: group holds immutable account role %s", groupID, accountRoleDisplayName(currentRole)) } if currentRole == newRole { return nil, annotations.New(&v2.GrantAlreadyExists{}), nil @@ -518,8 +523,9 @@ func (o *accountRoleBuilder) revokeAccountRoleFromGroup(ctx context.Context, pri return annos, nil } -func newAccountBuilder(client *client.Client) *accountRoleBuilder { +func newAccountBuilder(client *client.Client, syncGroups bool) *accountRoleBuilder { return &accountRoleBuilder{ - client: client, + client: client, + syncGroups: syncGroups, } } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index cb58610ae..d8bb7fedf 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -22,6 +22,8 @@ type Connector struct { accountID string accountCreationSettings AccountCreationSettings + + syncGroups bool } // ResourceSyncers returns a ResourceSyncer for each resource type that should be synced from the upstream service. @@ -29,9 +31,9 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso return []connectorbuilder.ResourceSyncerV2{ newUserBuilder(d.cloudServiceClient, d.accountCreationSettings), newServiceAccountBuilder(d.cloudServiceClient), - newNamespaceBuilder(d.cloudServiceClient), + newNamespaceBuilder(d.cloudServiceClient, d.syncGroups), newGroupBuilder(d.cloudServiceClient), - newAccountBuilder(d.cloudServiceClient), + newAccountBuilder(d.cloudServiceClient, d.syncGroups), } } @@ -106,11 +108,13 @@ func New(ctx context.Context, tc *cfg.TemporalCloud, opts *cli.ConnectorOpts) (c } defaultRole = *r } + connector := &Connector{ cloudServiceClient: c, accountCreationSettings: AccountCreationSettings{ DefaultAccountRole: defaultRole, }, + syncGroups: opts.WillSyncResourceType(groupResourceType.Id), } return connector, nil, nil @@ -130,11 +134,12 @@ type Opt func(*Connector) error // AccountAccess_ROLE_READ: "read", "role_read" func WithDefaultAccountRole(role string) Opt { return func(c *Connector) error { - r, err := AccountAccessRoleFromString(role) + role, err := AccountAccessRoleFromString(role) if err != nil { return err } - c.accountCreationSettings.DefaultAccountRole = *r + + c.accountCreationSettings.DefaultAccountRole = *role return nil } } diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index 5406e4514..d615cd276 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -3,7 +3,6 @@ package connector import ( "context" "fmt" - "strings" "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -56,7 +55,7 @@ func (o *groupBuilder) List(ctx context.Context, _ *v2.ResourceId, opts rs.SyncO resp, err := o.client.GetUserGroups(ctx, req) if err != nil { - if isPermissionDeniedWithOperatorWarning(err) { + if status.Code(err) == codes.PermissionDenied { ctxzap.Extract(ctx).Warn("baton-temporalcloud: cannot list user groups with the current API key, skipping groups", zap.Error(err)) return nil, nil, nil } @@ -75,21 +74,18 @@ func (o *groupBuilder) List(ctx context.Context, _ *v2.ResourceId, opts rs.SyncO return paginate(rv, bag, resp.GetNextPageToken()) } -// Entitlements emits the group member entitlement. Membership of SCIM and -// Google groups is owned by the external identity provider, so those -// entitlements are marked immutable. func (o *groupBuilder) Entitlements(_ context.Context, r *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { - grantsRails := []entitlement.EntitlementOption{ + options := []entitlement.EntitlementOption{ entitlement.WithGrantableTo(userResourceType), entitlement.WithDisplayName(fmt.Sprintf("%s Group Member", r.GetDisplayName())), entitlement.WithDescription(fmt.Sprintf("Member of the %s user group in Temporal Cloud", r.GetDisplayName())), } if isImmutablyProvisionedGroup(r) { - grantsRails = append(grantsRails, entitlement.WithAnnotation(&v2.EntitlementImmutable{})) + options = append(options, entitlement.WithAnnotation(&v2.EntitlementImmutable{})) } - member := entitlement.NewAssignmentEntitlement(r, groupMemberEntitlement, grantsRails...) + member := entitlement.NewAssignmentEntitlement(r, groupMemberEntitlement, options...) return []*v2.Entitlement{member}, nil, nil } @@ -119,44 +115,36 @@ func (o *groupBuilder) Grants(ctx context.Context, r *v2.Resource, opts rs.SyncO return nil, nil, fmt.Errorf("baton-temporalcloud: failed to list user group members: %w", err) } + groupKind := groupKindFromResource(r) rv := make([]*v2.Grant, 0, len(resp.GetMembers())) for _, member := range resp.GetMembers() { userID := member.GetMemberId().GetUserId() if userID == "" { - // Group members are users today; future member types surface no - // user id and are not representable as grants. continue } - ur, err := fetchUserResource(ctx, o.client, userID) - if err != nil { - if status.Code(err) == codes.NotFound { - ctxzap.Extract(ctx).Warn("baton-temporalcloud: skipping group member without matching user", - zap.String("group_id", r.GetId().GetResource()), - zap.String("user_id", userID)) - continue - } - return nil, nil, err + principalID := &v2.ResourceId{ + ResourceType: userResourceType.Id, + Resource: userID, } - g, err := createUserGroupMemberGrant(r, ur, isImmutablyProvisionedGroup(r)) - if err != nil { - return nil, nil, err + annos := []proto.Message{ + &v2.V1Identifier{ + Id: grantID(membershipEntitlementID(r.GetId().GetResource()), userID), + }, + } + if groupKind != groupKindCloud && groupKind != "" { + annos = append(annos, &v2.GrantImmutable{}) } + + g := grant.NewGrant(r, groupMemberEntitlement, principalID, grant.WithAnnotation(annos...)) rv = append(rv, g) } return paginate(rv, bag, resp.GetNextPageToken()) } -// Grant adds a user to a Cloud user group. Membership of SCIM and Google -// groups is managed by the external identity provider and cannot be granted -// through the Temporal Cloud API. func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { - if e.GetSlug() != groupMemberEntitlement { - return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected entitlement slug %q", e.GetSlug()) - } - groupID := e.GetResource().GetId().GetResource() userID := principal.GetId().GetResource() @@ -165,26 +153,21 @@ func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2. return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) } - group := groupResp.GetGroup() - if kind := groupKindFromSpec(group.GetSpec()); kind != groupKindCloud { + if kind := groupKindFromSpec(groupResp.GetGroup().GetSpec()); kind != groupKindCloud { return nil, nil, fmt.Errorf("baton-temporalcloud: %s groups are managed by the external identity provider and cannot be provisioned", kind) } - userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) - if err != nil { - return nil, nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve user: %w", err) - } - - resp, err := o.client.AddUserGroupMember(ctx, &cloudservicev1.AddUserGroupMemberRequest{ + req := &cloudservicev1.AddUserGroupMemberRequest{ GroupId: groupID, MemberId: &identityv1.UserGroupMemberId{ MemberType: &identityv1.UserGroupMemberId_UserId{ UserId: userID, }, }, - }) + } + resp, err := o.client.AddUserGroupMember(ctx, req) if err != nil { - if isAlreadyExistsError(err) { + if status.Code(err) == codes.AlreadyExists { return nil, annotations.New(&v2.GrantAlreadyExists{}), nil } return nil, nil, fmt.Errorf("baton-temporalcloud: could not add user to group: %w", err) @@ -204,20 +187,12 @@ func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2. return nil, nil, fmt.Errorf("baton-temporalcloud: group membership creation failed: %w", err) } - groupResource, err := protoUserGroupToResource(group) + groupResource, err := protoUserGroupToResource(groupResp.GetGroup()) if err != nil { return nil, nil, err } - user, err := protoUserToResource(userResp.GetUser()) - if err != nil { - return nil, nil, err - } - - g, err := createUserGroupMemberGrant(groupResource, user, false) - if err != nil { - return nil, nil, err - } + g := createUserGroupMemberGrant(groupResource, userID) annos := annotations.New() annos.Append(&v2.RequestId{RequestId: requestID}) @@ -225,25 +200,11 @@ func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2. return []*v2.Grant{g}, annos, nil } -// Revoke removes a user from a Cloud user group. func (o *groupBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { e := g.GetEntitlement() - if e.GetSlug() != groupMemberEntitlement { - return nil, fmt.Errorf("baton-temporalcloud: unexpected entitlement slug %q", e.GetSlug()) - } - groupID := e.GetResource().GetId().GetResource() userID := g.GetPrincipal().GetId().GetResource() - groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) - if err != nil { - return nil, fmt.Errorf("baton-temporalcloud: couldn't retrieve group: %w", err) - } - - if kind := groupKindFromSpec(groupResp.GetGroup().GetSpec()); kind != groupKindCloud { - return nil, fmt.Errorf("baton-temporalcloud: %s groups are managed by the external identity provider and cannot be provisioned", kind) - } - resp, err := o.client.RemoveUserGroupMember(ctx, &cloudservicev1.RemoveUserGroupMemberRequest{ GroupId: groupID, MemberId: &identityv1.UserGroupMemberId{ @@ -253,7 +214,7 @@ func (o *groupBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Ann }, }) if err != nil { - if isNotFoundError(err) { + if status.Code(err) == codes.NotFound { return annotations.New(&v2.GrantAlreadyRevoked{}), nil } return nil, fmt.Errorf("baton-temporalcloud: could not remove user from group: %w", err) @@ -283,37 +244,20 @@ func newGroupBuilder(client cloudservicev1.CloudServiceClient) *groupBuilder { return &groupBuilder{client: client} } -// fetchUserResource loads a user from Temporal Cloud and builds its resource. -// The caller is responsible for handling codes.NotFound. -func fetchUserResource(ctx context.Context, client cloudservicev1.CloudServiceClient, userID string) (*v2.Resource, error) { - userResp, err := client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) - if err != nil { - return nil, err - } - return protoUserToResource(userResp.GetUser()) -} - -// createUserGroupMemberGrant builds a membership grant for a user in a group, -// optionally marked immutable for groups whose membership is owned by an -// external identity provider. -func createUserGroupMemberGrant(group *v2.Resource, user *v2.Resource, immutable bool) (*v2.Grant, error) { +// createUserGroupMemberGrant builds a membership grant for a user in a group. +func createUserGroupMemberGrant(group *v2.Resource, userID string) *v2.Grant { annos := []proto.Message{ &v2.V1Identifier{ - Id: grantID(membershipEntitlementID(group.GetId().GetResource()), user.GetId().GetResource()), + Id: grantID(membershipEntitlementID(group.GetId().GetResource()), userID), }, } - if immutable { - annos = append(annos, &v2.GrantImmutable{}) - } - g := grant.NewGrant(group, groupMemberEntitlement, user.GetId(), grant.WithAnnotation(annos...)) - g.Principal = user - return g, nil + return grant.NewGrant(group, groupMemberEntitlement, &v2.ResourceId{ + ResourceType: userResourceType.Id, + Resource: userID, + }, grant.WithAnnotation(annos...)) } -// groupKindFromResource reads the group kind recorded in the group's profile -// during List. Returns "" when profile data is unavailable or does not carry -// the group kind. func groupKindFromResource(r *v2.Resource) string { profile := rs.GetProfile(r) if profile == nil { @@ -322,35 +266,7 @@ func groupKindFromResource(r *v2.Resource) string { return profile.GetFields()[groupKindProfileKey].GetStringValue() } -// isImmutablyProvisionedGroup reports whether a group's membership is owned by -// an external identity provider (SCIM or Google groups). Groups whose kind is -// unknown are treated as provisionable so entitlements remain available. func isImmutablyProvisionedGroup(r *v2.Resource) bool { kind := groupKindFromResource(r) return kind == groupKindScim || kind == groupKindGoogle } - -// isPermissionDeniedWithOperatorWarning reports whether a API call couldn't be -// made due to missing permissions. The API key may lack the account role -// required to manage user groups (Owner or Global Admin); in that case the -// rest of a sync should not fail. -func isPermissionDeniedWithOperatorWarning(err error) bool { - return status.Code(err) == codes.PermissionDenied -} - -// isAlreadyExistsError reports whether the API call returned an already-exists -// error, which is treated as idempotent success. -func isAlreadyExistsError(err error) bool { - if status.Code(err) == codes.AlreadyExists { - return true - } - return strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "already a member") -} - -// isNotFoundError reports whether the API call returned a not-found error. -func isNotFoundError(err error) bool { - if status.Code(err) == codes.NotFound { - return true - } - return strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not a member") -} diff --git a/pkg/connector/namespaces.go b/pkg/connector/namespaces.go index 40055715c..de5e20b1e 100644 --- a/pkg/connector/namespaces.go +++ b/pkg/connector/namespaces.go @@ -33,7 +33,8 @@ var namespaceAccessLevels = []identityv1.NamespaceAccess_Permission{ } type namespaceBuilder struct { - client cloudservicev1.CloudServiceClient + client cloudservicev1.CloudServiceClient + syncGroups bool } func (o *namespaceBuilder) ResourceType(ctx context.Context) *v2.ResourceType { @@ -105,10 +106,12 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op ResourceTypeID: namespacePhaseUsers, ResourceID: resource.GetId().GetResource(), }) - bag.Push(pagination.PageState{ - ResourceTypeID: namespacePhaseGroups, - ResourceID: resource.GetId().GetResource(), - }) + if o.syncGroups { + bag.Push(pagination.PageState{ + ResourceTypeID: namespacePhaseGroups, + ResourceID: resource.GetId().GetResource(), + }) + } } var rv []*v2.Grant @@ -119,7 +122,9 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op case namespacePhaseGroups: rv, nextPageToken, err = o.listGroupNamespaceGrants(ctx, resource, bag) default: - return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected namespace grants pagination phase %q", bag.ResourceTypeID()) + // Legacy page states from the previous connector version used the + // resource type id as the state marker; treat as the users phase. + rv, nextPageToken, err = o.listUserNamespaceGrants(ctx, resource, bag) } if err != nil { return nil, nil, err @@ -352,17 +357,17 @@ func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations namespaceType := namespace.GetId().GetResourceType() if principalType == groupResourceType.Id { - return o.revokeNamespaceAccessFromGroup(ctx, g, principalID, namespaceID, namespaceType, entitlementID) + return o.revokeNamespaceAccessFromGroup(ctx, principalID, namespaceID, namespaceType, entitlementID) } if principalType == userResourceType.Id { - return o.revokeNamespaceAccessFromUser(ctx, g, principalID, principalType, namespaceID, namespaceType, entitlementID) + return o.revokeNamespaceAccessFromUser(ctx, principalID, principalType, namespaceID, namespaceType, entitlementID) } return nil, fmt.Errorf("baton-temporalcloud: unsupported principal type %s", principalType) } func (o *namespaceBuilder) revokeNamespaceAccessFromUser( - ctx context.Context, _ *v2.Grant, userID string, userType string, + ctx context.Context, userID string, userType string, namespaceID string, namespaceType string, entitlementID string, ) (annotations.Annotations, error) { userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) @@ -407,7 +412,7 @@ func (o *namespaceBuilder) revokeNamespaceAccessFromUser( } func (o *namespaceBuilder) revokeNamespaceAccessFromGroup( - ctx context.Context, _ *v2.Grant, groupID string, + ctx context.Context, groupID string, namespaceID string, namespaceType string, entitlementID string, ) (annotations.Annotations, error) { groupResp, err := o.client.GetUserGroup(ctx, &cloudservicev1.GetUserGroupRequest{GroupId: groupID}) @@ -453,6 +458,6 @@ func (o *namespaceBuilder) revokeNamespaceAccessFromGroup( return annos, nil } -func newNamespaceBuilder(client cloudservicev1.CloudServiceClient) *namespaceBuilder { - return &namespaceBuilder{client: client} +func newNamespaceBuilder(client cloudservicev1.CloudServiceClient, syncGroups bool) *namespaceBuilder { + return &namespaceBuilder{client: client, syncGroups: syncGroups} } From 29a3495d3847bf38ce7fe2cd43d995be4c7f468f Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 17 Aug 2026 20:37:08 +0000 Subject: [PATCH 5/5] fix: update docs capability table with User Groups Co-authored-by: c1-squire-dev[bot] --- docs/connector.mdx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 951181940..0fec687cd 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -8,14 +8,16 @@ sidebarTitle: Temporal Cloud ## Capabilities -| Resource | Sync | Provision | -| :--- | :--- | :--- | -| Accounts | | | -| Namespaces | | | -| Account roles* | | | -| Service accounts | | | - -*The Account Owner and Finance Manager roles are synced but cannot be provisioned. +| Resource | Sync | Provision | +| :--- | :--- | :--- | +| Accounts | | | +| Namespaces | | | +| User Groups \*\* | | | +| Account roles \* | | | +| Service accounts | | | + +\*The Account Owner and Finance Manager roles are synced but cannot be provisioned. +\*\*User Groups are opt-in and require an API key with the Owner or Global Admin account role. Membership of SCIM and Google groups is externally managed and cannot be provisioned by C1. The Temporal Cloud connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning).