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..af6a665a3 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -15,6 +15,26 @@
],
"permissions": {}
},
+ {
+ "resourceType": {
+ "id": "group",
+ "displayName": "User Group",
+ "traits": [
+ "TRAIT_GROUP"
+ ],
+ "annotations": [
+ {
+ "@type": "type.googleapis.com/c1.connector.v2.OptInRequired"
+ }
+ ]
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_PROVISION"
+ ],
+ "permissions": {},
+ "optInRequired": true
+ },
{
"resourceType": {
"id": "namespace",
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).
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/account_roles.go b/pkg/connector/account_roles.go
index 476b4c3a5..976ab9062 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{
@@ -39,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 {
@@ -82,7 +86,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 +106,37 @@ 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,
})
+ if o.syncGroups {
+ 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:
+ // 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
+ }
+
+ 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 +144,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 +199,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 +285,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) {
+ 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
+ }
+
+ 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 +366,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,8 +456,76 @@ func (o *accountRoleBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotatio
return annos, nil
}
-func newAccountBuilder(client *client.Client) *accountRoleBuilder {
+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, syncGroups bool) *accountRoleBuilder {
return &accountRoleBuilder{
- client: client,
+ client: client,
+ syncGroups: syncGroups,
}
}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index f543033c6..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,8 +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),
- newAccountBuilder(d.cloudServiceClient),
+ newNamespaceBuilder(d.cloudServiceClient, d.syncGroups),
+ newGroupBuilder(d.cloudServiceClient),
+ newAccountBuilder(d.cloudServiceClient, d.syncGroups),
}
}
@@ -111,6 +114,7 @@ func New(ctx context.Context, tc *cfg.TemporalCloud, opts *cli.ConnectorOpts) (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
new file mode 100644
index 000000000..d615cd276
--- /dev/null
+++ b/pkg/connector/groups.go
@@ -0,0 +1,272 @@
+package connector
+
+import (
+ "context"
+ "fmt"
+ "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 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
+ }
+ 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())
+}
+
+func (o *groupBuilder) Entitlements(_ context.Context, r *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
+ 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) {
+ options = append(options, entitlement.WithAnnotation(&v2.EntitlementImmutable{}))
+ }
+
+ member := entitlement.NewAssignmentEntitlement(r, groupMemberEntitlement, options...)
+ 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)
+ }
+
+ groupKind := groupKindFromResource(r)
+ rv := make([]*v2.Grant, 0, len(resp.GetMembers()))
+ for _, member := range resp.GetMembers() {
+ userID := member.GetMemberId().GetUserId()
+ if userID == "" {
+ continue
+ }
+
+ principalID := &v2.ResourceId{
+ ResourceType: userResourceType.Id,
+ Resource: userID,
+ }
+
+ 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())
+}
+
+func (o *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, e *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {
+ 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)
+ }
+
+ 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)
+ }
+
+ 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 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)
+ }
+
+ 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(groupResp.GetGroup())
+ if err != nil {
+ return nil, nil, err
+ }
+
+ g := createUserGroupMemberGrant(groupResource, userID)
+
+ 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) {
+ e := g.GetEntitlement()
+ groupID := e.GetResource().GetId().GetResource()
+ userID := g.GetPrincipal().GetId().GetResource()
+
+ resp, err := o.client.RemoveUserGroupMember(ctx, &cloudservicev1.RemoveUserGroupMemberRequest{
+ GroupId: groupID,
+ MemberId: &identityv1.UserGroupMemberId{
+ MemberType: &identityv1.UserGroupMemberId_UserId{
+ UserId: userID,
+ },
+ },
+ })
+ if err != nil {
+ 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)
+ }
+
+ 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}
+}
+
+// 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()), userID),
+ },
+ }
+
+ return grant.NewGrant(group, groupMemberEntitlement, &v2.ResourceId{
+ ResourceType: userResourceType.Id,
+ Resource: userID,
+ }, grant.WithAnnotation(annos...))
+}
+
+func groupKindFromResource(r *v2.Resource) string {
+ profile := rs.GetProfile(r)
+ if profile == nil {
+ return ""
+ }
+ return profile.GetFields()[groupKindProfileKey].GetStringValue()
+}
+
+func isImmutablyProvisionedGroup(r *v2.Resource) bool {
+ kind := groupKindFromResource(r)
+ return kind == groupKindScim || kind == groupKindGoogle
+}
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..de5e20b1e 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{
@@ -28,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 {
@@ -81,7 +87,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 +103,37 @@ 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(),
})
+ if o.syncGroups {
+ 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:
+ // 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
}
+ 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 +141,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 +153,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 +213,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 +246,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 +258,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 +275,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 +289,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, principalID, namespaceID, namespaceType, entitlementID)
+ }
+
+ if principalType == userResourceType.Id {
+ 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, 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 +385,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 +402,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, 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()
@@ -258,6 +458,6 @@ func (o *namespaceBuilder) Revoke(ctx context.Context, g *v2.Grant) (annotations
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}
}
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
index fef141c85..be96d9bea 100644
--- a/pkg/connector/resource_types.go
+++ b/pkg/connector/resource_types.go
@@ -22,6 +22,13 @@ 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},
+ Annotations: annotations.New(&v2.OptInRequired{}),
+}
+
var namespaceResourceType = &v2.ResourceType{
Id: "namespace",
DisplayName: "Namespace",