diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 1af7a79c1..c2079d229 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), newNamespaceBuilder(d.cloudServiceClient), newAccountBuilder(d.cloudServiceClient), + newGroupBuilder(d.cloudServiceClient), } } diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go new file mode 100644 index 000000000..665a9c8a7 --- /dev/null +++ b/pkg/connector/groups.go @@ -0,0 +1,236 @@ +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/pagination" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + 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" +) + +const ( + GroupMembershipMaxDuration = 10 * time.Minute +) + +type groupBuilder struct { + client cloudservicev1.CloudServiceClient +} + +func (o *groupBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return groupResourceType +} + +func (o *groupBuilder) List(ctx context.Context, parentResourceID *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 { + return nil, nil, fmt.Errorf("baton-temporalcloud: failed to list 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()) +} + +func (o *groupBuilder) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + annos := &v2.V1Identifier{ + Id: membershipEntitlementID(resource.GetId().GetResource()), + } + member := entitlement.NewAssignmentEntitlement(resource, roleMemberEntitlement, + entitlement.WithGrantableTo(userResourceType), + entitlement.WithDescription(fmt.Sprintf("Member of %s group in Temporal Cloud", resource.GetDisplayName())), + entitlement.WithDisplayName(fmt.Sprintf("%s Group Member", resource.GetDisplayName())), + entitlement.WithAnnotation(annos), + ) + return []*v2.Entitlement{member}, nil, nil +} + +func (o *groupBuilder) Grants(ctx context.Context, resource *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: resource.GetId().GetResourceType(), + ResourceID: resource.GetId().GetResource(), + }) + } + + req := &cloudservicev1.GetUserGroupMembersRequest{ + GroupId: resource.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 group members: %w", err) + } + + var rv []*v2.Grant + for _, member := range resp.GetMembers() { + userID := member.GetMemberId().GetUserId() + if userID == "" { + continue + } + + userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: failed to get user %s for group grant: %w", userID, err) + } + + g, err := createGroupMemberGrant(userResp.GetUser(), resource) + if err != nil { + return nil, nil, err + } + rv = append(rv, g) + } + + return paginate(rv, bag, resp.GetNextPageToken()) +} + +func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + userID := principal.GetId().GetResource() + userType := principal.GetId().GetResourceType() + groupResource := e.GetResource() + groupID := groupResource.GetId().GetResource() + groupType := groupResource.GetId().GetResourceType() + entitlementID := e.GetId() + + 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 strings.Contains(err.Error(), "already a member") || strings.Contains(err.Error(), "nothing to change") { + 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("principal_type", userType), + zap.String("entitlement_id", entitlementID), + zap.String("entitlement_resource_id", groupID), + zap.String("entitlement_resource_type", groupType), + ) + waitCtx, cancel := context.WithTimeout(ctx, GroupMembershipMaxDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: group membership addition failed: %w", err) + } + + userResp, err := o.client.GetUser(ctx, &cloudservicev1.GetUserRequest{UserId: userID}) + if err != nil { + return nil, nil, fmt.Errorf("baton-temporalcloud: failed to get user after adding to group: %w", err) + } + + g, err := createGroupMemberGrant(userResp.GetUser(), groupResource) + if err != nil { + return nil, nil, err + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return []*v2.Grant{g}, annos, nil +} + +func (o *groupBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { + userID := g.GetPrincipal().GetId().GetResource() + userType := g.GetPrincipal().GetId().GetResourceType() + entitlementID := g.GetEntitlement().GetId() + groupResource := g.GetEntitlement().GetResource() + groupID := groupResource.GetId().GetResource() + groupType := groupResource.GetId().GetResourceType() + + req := &cloudservicev1.RemoveUserGroupMemberRequest{ + GroupId: groupID, + MemberId: &identityv1.UserGroupMemberId{ + MemberType: &identityv1.UserGroupMemberId_UserId{ + UserId: userID, + }, + }, + } + + resp, err := o.client.RemoveUserGroupMember(ctx, req) + if err != nil { + if strings.Contains(err.Error(), "not a member") || strings.Contains(err.Error(), "nothing to change") { + annos := annotations.New(&v2.GrantAlreadyRevoked{}) + return annos, fmt.Errorf("baton-temporalcloud: user is not a member of this group") + } + 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("principal_type", userType), + zap.String("entitlement_id", entitlementID), + zap.String("entitlement_resource_id", groupID), + zap.String("entitlement_resource_type", groupType), + ) + waitCtx, cancel := context.WithTimeout(ctx, GroupMembershipMaxDuration) + defer cancel() + err = awaitAsyncOperation(waitCtx, l, o.client, requestID, retryDelay) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: group membership removal 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} +} diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 1f9d614ce..149bc7f8e 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -106,6 +106,68 @@ func createAccountRoleGrant(user *identityv1.User, ar *v2.Resource, accountID st return g, nil } +func protoUserGroupToResource(proto *identityv1.UserGroup) (*v2.Resource, error) { + annos := &v2.V1Identifier{ + Id: fmt.Sprintf("group:%s", proto.GetId()), + } + + var profile map[string]interface{} + switch { + case proto.GetSpec().GetGoogleGroup() != nil: + profile = map[string]interface{}{ + "group_type": "google", + "email_address": proto.GetSpec().GetGoogleGroup().GetEmailAddress(), + } + case proto.GetSpec().GetScimGroup() != nil: + profile = map[string]interface{}{ + "group_type": "scim", + "idp_id": proto.GetSpec().GetScimGroup().GetIdpId(), + } + case proto.GetSpec().GetCloudGroup() != nil: + profile = map[string]interface{}{ + "group_type": "cloud", + } + } + + var groupTraitOpts []rs.GroupTraitOption + if profile != nil { + groupTraitOpts = append(groupTraitOpts, rs.WithGroupProfile(profile)) + } + + group, err := rs.NewGroupResource(proto.GetSpec().GetDisplayName(), groupResourceType, proto.GetId(), groupTraitOpts, rs.WithAnnotation(annos)) + if err != nil { + return nil, err + } + return group, nil +} + +func createGroupMemberGrant(user *identityv1.User, group *v2.Resource) (*v2.Grant, error) { + ur, err := protoUserToResource(user) + if err != nil { + return nil, err + } + annos := &v2.V1Identifier{ + Id: grantID(membershipEntitlementID(group.GetId().GetResource()), ur.GetId().GetResource()), + } + g := grant.NewGrant(group, roleMemberEntitlement, ur.GetId(), grant.WithAnnotation(annos)) + g.Principal = ur + return g, nil +} + +func createNamespaceGroupGrant(group *identityv1.UserGroup, namespace *v2.Resource, permission identityv1.NamespaceAccess_Permission) (*v2.Grant, error) { + perm := namespacePermissionName(permission) + gr, err := protoUserGroupToResource(group) + if err != nil { + return nil, err + } + annos := &v2.V1Identifier{ + Id: grantID(namespaceEntitlementID(namespace.GetId().GetResource(), perm), gr.GetId().GetResource()), + } + g := grant.NewGrant(namespace, perm, gr.GetId(), grant.WithAnnotation(annos)) + g.Principal = gr + return g, nil +} + 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 { diff --git a/pkg/connector/namespaces.go b/pkg/connector/namespaces.go index 85efbdb9b..d5df1c0d5 100644 --- a/pkg/connector/namespaces.go +++ b/pkg/connector/namespaces.go @@ -81,7 +81,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 +97,27 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op } if bag.Current() == nil { bag.Push(pagination.PageState{ - ResourceTypeID: resource.GetId().GetResourceType(), + ResourceTypeID: groupResourceType.Id, ResourceID: resource.GetId().GetResource(), }) + bag.Push(pagination.PageState{ + ResourceTypeID: userResourceType.Id, + ResourceID: resource.GetId().GetResource(), + }) + } + + current := bag.Current() + switch current.ResourceTypeID { + case userResourceType.Id: + return o.listUserNamespaceGrants(ctx, resource, bag) + case groupResourceType.Id: + return o.listGroupNamespaceGrants(ctx, resource, bag) + default: + return nil, nil, fmt.Errorf("baton-temporalcloud: unexpected resource type in namespace grants pagination: %s", current.ResourceTypeID) } +} +func (o *namespaceBuilder) listUserNamespaceGrants(ctx context.Context, resource *v2.Resource, bag *pagination.Bag) ([]*v2.Grant, *rs.SyncOpResults, error) { req := &cloudservicev1.GetUsersRequest{Namespace: resource.GetDisplayName()} if bag.PageToken() != "" { req.PageToken = bag.PageToken() @@ -129,7 +145,48 @@ func (o *namespaceBuilder) Grants(ctx context.Context, resource *v2.Resource, op return paginate(rv, bag, resp.GetNextPageToken()) } +func (o *namespaceBuilder) listGroupNamespaceGrants(ctx context.Context, resource *v2.Resource, bag *pagination.Bag) ([]*v2.Grant, *rs.SyncOpResults, error) { + req := &cloudservicev1.GetUserGroupsRequest{Namespace: resource.GetDisplayName()} + if bag.PageToken() != "" { + req.PageToken = bag.PageToken() + } + + resp, err := o.client.GetUserGroups(ctx, req) + if err != nil { + return nil, nil, err + } + + var rv []*v2.Grant + for _, group := range resp.GetGroups() { + permission, hasPerm := group.GetSpec().GetAccess().GetNamespaceAccesses()[resource.GetId().GetResource()] + if !hasPerm { + continue + } + + g, err := createNamespaceGroupGrant(group, resource, permission.GetPermission()) + if err != nil { + return nil, nil, err + } + rv = append(rv, g) + } + + return paginate(rv, bag, resp.GetNextPageToken()) +} + func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + principalType := principal.GetId().GetResourceType() + + switch principalType { + case userResourceType.Id: + return o.grantUserNamespaceAccess(ctx, principal, e) + case groupResourceType.Id: + return o.grantGroupNamespaceAccess(ctx, principal, e) + default: + return nil, nil, fmt.Errorf("baton-temporalcloud: unsupported principal type %s for namespace grant", principalType) + } +} + +func (o *namespaceBuilder) grantUserNamespaceAccess(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() @@ -209,7 +266,89 @@ func (o *namespaceBuilder) Grant(ctx context.Context, principal *v2.Resource, e return []*v2.Grant{g}, annos, nil } +func (o *namespaceBuilder) grantGroupNamespaceAccess(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + entitlementID := e.GetId() + groupID := principal.GetId().GetResource() + groupType := principal.GetId().GetResourceType() + namespace := e.GetResource() + namespaceID := namespace.GetId().GetResource() + namespaceType := namespace.GetId().GetResourceType() + + enIDParts := strings.Split(entitlementID, ":") + if len(enIDParts) != 3 { + return nil, nil, fmt.Errorf("baton-temporalcloud: invalid entitlement ID %s", entitlementID) + } + + nsRole := enIDParts[2] + + namespaceRole := namespaceAccessPermissionFromString(nsRole) + if namespaceRole == identityv1.NamespaceAccess_PERMISSION_UNSPECIFIED { + return nil, nil, fmt.Errorf("baton-temporalcloud: invalid namespace permission %s", nsRole) + } + + 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() + + req := &cloudservicev1.SetUserGroupNamespaceAccessRequest{ + Namespace: namespaceID, + GroupId: groupID, + Access: &identityv1.NamespaceAccess{Permission: namespaceRole}, + ResourceVersion: group.GetResourceVersion(), + } + + resp, err := o.client.SetUserGroupNamespaceAccess(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 namespace access 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", groupID), + zap.String("principal_type", groupType), + 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, nil, fmt.Errorf("baton-temporalcloud: namespace assignment creation for group failed: %w", err) + } + + g, err := createNamespaceGroupGrant(group, namespace, namespaceRole) + if err != nil { + return nil, nil, err + } + + 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) { + principalType := g.GetPrincipal().GetId().GetResourceType() + + switch principalType { + case userResourceType.Id: + return o.revokeUserNamespaceAccess(ctx, g) + case groupResourceType.Id: + return o.revokeGroupNamespaceAccess(ctx, g) + default: + return nil, fmt.Errorf("baton-temporalcloud: unsupported principal type %s for namespace revoke", principalType) + } +} + +func (o *namespaceBuilder) revokeUserNamespaceAccess(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { userID := g.GetPrincipal().GetId().GetResource() userType := g.GetPrincipal().GetId().GetResourceType() entitlementID := g.GetEntitlement().GetId() @@ -259,6 +398,61 @@ func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations return annos, nil } +func (o *namespaceBuilder) revokeGroupNamespaceAccess(ctx context.Context, g *v2.Grant) (annotations.Annotations, error) { + groupID := g.GetPrincipal().GetId().GetResource() + groupType := g.GetPrincipal().GetId().GetResourceType() + entitlementID := g.GetEntitlement().GetId() + namespace := g.GetEntitlement().GetResource() + namespaceID := namespace.GetId().GetResource() + namespaceType := namespace.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() + + _, ok := group.GetSpec().GetAccess().GetNamespaceAccesses()[namespaceID] + if !ok { + annos := annotations.New(&v2.GrantAlreadyRevoked{}) + return annos, fmt.Errorf("baton-temporalcloud: grant does not exist for group") + } + + // Setting access to nil removes the namespace access for the group + req := &cloudservicev1.SetUserGroupNamespaceAccessRequest{ + Namespace: namespaceID, + GroupId: groupID, + ResourceVersion: group.GetResourceVersion(), + } + + resp, err := o.client.SetUserGroupNamespaceAccess(ctx, req) + if err != nil { + return nil, fmt.Errorf("baton-temporalcloud: could not revoke namespace access 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("principal_id", groupID), + zap.String("principal_type", groupType), + 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 assignment deletion for group failed: %w", err) + } + + annos := annotations.New() + annos.Append(&v2.RequestId{RequestId: requestID}) + + return annos, nil +} + func newNamespaceBuilder(client cloudservicev1.CloudServiceClient) *namespaceBuilder { return &namespaceBuilder{client: client} } diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 70ca1d781..2a1dde5a4 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -21,3 +21,9 @@ var accountRoleResourceType = &v2.ResourceType{ DisplayName: "Account Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, } + +var groupResourceType = &v2.ResourceType{ + Id: "group", + DisplayName: "Group", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +}