From 07898a809f1416a0dc391c6505d1b4c2c9f3bfa1 Mon Sep 17 00:00:00 2001 From: Marcus Goldschmidt Date: Fri, 15 Aug 2025 11:33:13 -0400 Subject: [PATCH 1/3] add user tokens --- README.md | 2 +- pkg/connector/connector.go | 8 +++++ pkg/connector/user.go | 3 ++ pkg/connector/user_token.go | 72 +++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/user_token.go diff --git a/README.md b/README.md index 8b17ada6..74db4986 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Baton Logo](./docs/images/baton-logo.png) + ![Baton Logo](./docs/images/baton-logo.png) # `baton-google-workspace` [![Go Reference](https://pkg.go.dev/badge/github.com/conductorone/baton-google-workspace.svg)](https://pkg.go.dev/github.com/conductorone/baton-google-workspace) ![main ci](https://github.com/conductorone/baton-google-workspace/actions/workflows/main.yaml/badge.svg) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 33e01f8f..b8c9f667 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -79,6 +79,11 @@ var ( }, }, } + + resourceTypeUserToken = &v2.ResourceType{ + Id: "user_token", + DisplayName: "User Tokens", + } ) type Config struct { @@ -296,6 +301,9 @@ func (c *GoogleWorkspace) ResourceSyncers(ctx context.Context) []connectorbuilde )) } } + + rs = append(rs, newUserTokenResource(userService)) + return rs } diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 440c76d6..6a816998 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -374,6 +374,9 @@ func (o *userResourceType) userResource(ctx context.Context, user *admin.User) ( Id: user.Id, }, ), + sdkResource.WithAnnotation(&v2.ChildResourceType{ + ResourceTypeId: resourceTypeUserToken.Id, + }), ) return userResource, err } diff --git a/pkg/connector/user_token.go b/pkg/connector/user_token.go new file mode 100644 index 00000000..bb4791fb --- /dev/null +++ b/pkg/connector/user_token.go @@ -0,0 +1,72 @@ +package connector + +import ( + "context" + "fmt" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + admin "google.golang.org/api/admin/directory/v1" +) + +type userTokenResource struct { + userService *admin.Service +} + +func newUserTokenResource(userService *admin.Service) *userTokenResource { + return &userTokenResource{userService: userService} +} + +func (u userTokenResource) ResourceType(ctx context.Context) *v2.ResourceType { + return resourceTypeUserToken +} + +func (u userTokenResource) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + if parentResourceID == nil { + l.Info("Skipping user token resource type list, only supported as a child resource type") + return nil, "", nil, nil + } + + if parentResourceID.ResourceType != resourceTypeUser.Id { + return nil, "", nil, fmt.Errorf("invalid resource type: %s", parentResourceID.ResourceType) + } + + userKey := parentResourceID.Resource + + doResponse, err := u.userService.Tokens.List(userKey).Context(ctx).Do() + if err != nil { + return nil, "", nil, err + } + + rv := make([]*v2.Resource, 0, len(doResponse.Items)) + for _, token := range doResponse.Items { + rs, err := sdkResource.NewResource( + token.DisplayText, + resourceTypeUserToken, + fmt.Sprintf("%s/%s", token.UserKey, token.ClientId), + sdkResource.WithParentResourceID(parentResourceID), + ) + + if err != nil { + l.Error("Failed to create resource for user token", zap.Error(err)) + continue + } + + rv = append(rv, rs) + } + + return rv, "", nil, nil +} + +func (u userTokenResource) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + return nil, "", nil, nil +} + +func (u userTokenResource) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + return nil, "", nil, nil +} From a29c49fea309aa079f7e76f05082f72076a07bcf Mon Sep 17 00:00:00 2001 From: Marcus Goldschmidt Date: Fri, 15 Aug 2025 13:15:42 -0400 Subject: [PATCH 2/3] add sync tokens option --- README.md | 2 +- cmd/baton-google-workspace/config.go | 6 ++++++ cmd/baton-google-workspace/main.go | 2 ++ pkg/connector/connector.go | 9 ++++++-- pkg/connector/user.go | 32 ++++++++++++++++++++-------- pkg/connector/user_token.go | 1 + 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 74db4986..8b17ada6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - ![Baton Logo](./docs/images/baton-logo.png) +![Baton Logo](./docs/images/baton-logo.png) # `baton-google-workspace` [![Go Reference](https://pkg.go.dev/badge/github.com/conductorone/baton-google-workspace.svg)](https://pkg.go.dev/github.com/conductorone/baton-google-workspace) ![main ci](https://github.com/conductorone/baton-google-workspace/actions/workflows/main.yaml/badge.svg) diff --git a/cmd/baton-google-workspace/config.go b/cmd/baton-google-workspace/config.go index 815549cd..e6c9374c 100644 --- a/cmd/baton-google-workspace/config.go +++ b/cmd/baton-google-workspace/config.go @@ -41,6 +41,11 @@ var ( field.WithDescription("JSON credentials for the Google Workspace account. Mutually exclusive with file path"), ) + SyncTokensField = field.BoolField( + "sync-tokens", + field.WithDescription("Sync third party tokens for the Google Workspace account."), + ) + // Collection of all configuration fields. ConfigurationFields = []field.SchemaField{ CustomerIDField, @@ -48,6 +53,7 @@ var ( AdministratorEmailField, CredentialsJSONFilePathField, CredentialsJSONField, + SyncTokensField, } // Configuration combines fields into a single configuration object. diff --git a/cmd/baton-google-workspace/main.go b/cmd/baton-google-workspace/main.go index 5418725f..89d00dd6 100644 --- a/cmd/baton-google-workspace/main.go +++ b/cmd/baton-google-workspace/main.go @@ -49,6 +49,7 @@ func getConnector(ctx context.Context, v *viper.Viper) (types.ConnectorServer, e administratorEmail := v.GetString(AdministratorEmailField.FieldName) credentialsJSONFilePath := v.GetString(CredentialsJSONFilePathField.FieldName) credentialsJSON := v.GetString(CredentialsJSONField.FieldName) + syncTokens := v.GetBool(SyncTokensField.FieldName) var jsonCredentials []byte @@ -83,6 +84,7 @@ func getConnector(ctx context.Context, v *viper.Viper) (types.ConnectorServer, e AdministratorEmail: administratorEmail, Domain: domain, Credentials: jsonCredentials, + SyncTokens: syncTokens, } // Create the Google Workspace connector diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index b8c9f667..50f26893 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -91,6 +91,7 @@ type Config struct { AdministratorEmail string Domain string Credentials []byte + SyncTokens bool } type GoogleWorkspace struct { @@ -107,6 +108,7 @@ type GoogleWorkspace struct { primaryDomain string domainsCache []string reportService *reportsAdmin.Service + syncTokens bool } type newService[T any] func(ctx context.Context, opts ...option.ClientOption) (*T, error) @@ -174,6 +176,7 @@ func New(ctx context.Context, config Config) (*GoogleWorkspace, error) { credentials: config.Credentials, serviceCache: map[string]any{}, domain: config.Domain, + syncTokens: config.SyncTokens, } return rv, nil } @@ -283,7 +286,7 @@ func (c *GoogleWorkspace) ResourceSyncers(ctx context.Context) []connectorbuilde userService, err := c.getDirectoryService(ctx, directoryAdmin.AdminDirectoryUserReadonlyScope) if err == nil { - rs = append(rs, userBuilder(userService, c.customerID, c.domain)) + rs = append(rs, userBuilder(userService, c.customerID, c.domain, c.syncTokens)) } // We don't care about the error here, as we handle the case where the service is nil in the syncer @@ -302,7 +305,9 @@ func (c *GoogleWorkspace) ResourceSyncers(ctx context.Context) []connectorbuilde } } - rs = append(rs, newUserTokenResource(userService)) + if c.syncTokens { + rs = append(rs, newUserTokenResource(userService)) + } return rs } diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 6a816998..6a024805 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -22,6 +22,7 @@ type userResourceType struct { userService *admin.Service customerId string domain string + syncTokens bool } func (o *userResourceType) ResourceType(_ context.Context) *v2.ResourceType { @@ -112,12 +113,13 @@ func (o *userResourceType) Grants(_ context.Context, _ *v2.Resource, _ *paginati return nil, "", nil, nil } -func userBuilder(userService *admin.Service, customerId string, domain string) *userResourceType { +func userBuilder(userService *admin.Service, customerId string, domain string, syncTokens bool) *userResourceType { return &userResourceType{ resourceType: resourceTypeUser, userService: userService, customerId: customerId, domain: domain, + syncTokens: syncTokens, } } @@ -364,19 +366,31 @@ func (o *userResourceType) userResource(ctx context.Context, user *admin.User) ( sdkResource.WithUserLogin(user.PrimaryEmail, additionalLogins.ToSlice()...), ) - userResource, err := sdkResource.NewUserResource( - user.Name.FullName, - resourceTypeUser, - user.Id, - traitOpts, + rsOption := []sdkResource.ResourceOption{ sdkResource.WithAnnotation( &v2.V1Identifier{ Id: user.Id, }, ), - sdkResource.WithAnnotation(&v2.ChildResourceType{ - ResourceTypeId: resourceTypeUserToken.Id, - }), + } + + if o.syncTokens { + rsOption = append( + rsOption, + sdkResource.WithAnnotation( + &v2.ChildResourceType{ + ResourceTypeId: resourceTypeUserToken.Id, + }, + ), + ) + } + + userResource, err := sdkResource.NewUserResource( + user.Name.FullName, + resourceTypeUser, + user.Id, + traitOpts, + rsOption..., ) return userResource, err } diff --git a/pkg/connector/user_token.go b/pkg/connector/user_token.go index bb4791fb..ed189c2c 100644 --- a/pkg/connector/user_token.go +++ b/pkg/connector/user_token.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" From c76862fe897f57b5370ad4a96f354a330f504d1e Mon Sep 17 00:00:00 2001 From: Marcus Goldschmidt Date: Mon, 25 Aug 2025 14:40:40 -0400 Subject: [PATCH 3/3] add tokens sync --- README.md | 21 +++++++++- pkg/connector/connector.go | 7 +++- pkg/connector/user_token.go | 78 +++++++++++++++++++++++++++++++++---- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8b17ada6..a3f9778d 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,32 @@ baton resources # Data Model `baton-google-workspace` will pull down information about the following Google Workspace resources: + - Groups - Users - Roles +- Tokens + +## Scope Permissions + +In Admin Console → Security → API controls → Manage domain-wide delegation, authorize the service account client ID with +those scopes. + +- https://www.googleapis.com/auth/admin.directory.rolemanagement +- https://www.googleapis.com/auth/admin.directory.user.alias.readonly +- https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly +- https://www.googleapis.com/auth/admin.directory.group.member.readonly +- https://www.googleapis.com/auth/admin.directory.group.readonly +- https://www.googleapis.com/auth/admin.directory.user.readonly +- https://www.googleapis.com/auth/admin.directory.domain.readonly +- https://www.googleapis.com/auth/admin.reports.audit.readonly +- https://www.googleapis.com/auth/admin.directory.user.security # Contributing, Support and Issues -We started Baton because we were tired of taking screenshots and manually building spreadsheets. We welcome contributions, and ideas, no matter how small -- our goal is to make identity and permissions sprawl less painful for everyone. If you have questions, problems, or ideas: Please open a Github Issue! +We started Baton because we were tired of taking screenshots and manually building spreadsheets. We welcome +contributions, and ideas, no matter how small -- our goal is to make identity and permissions sprawl less painful for +everyone. If you have questions, problems, or ideas: Please open a Github Issue! See [CONTRIBUTING.md](https://github.com/ConductorOne/baton/blob/main/CONTRIBUTING.md) for more details. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 50f26893..c71b7840 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -83,6 +83,9 @@ var ( resourceTypeUserToken = &v2.ResourceType{ Id: "user_token", DisplayName: "User Tokens", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_APP, + }, } ) @@ -306,7 +309,9 @@ func (c *GoogleWorkspace) ResourceSyncers(ctx context.Context) []connectorbuilde } if c.syncTokens { - rs = append(rs, newUserTokenResource(userService)) + if userTokenService, err := c.getDirectoryService(ctx, directoryAdmin.AdminDirectoryUserSecurityScope); err == nil { + rs = append(rs, newUserTokenResource(userTokenService)) + } } return rs diff --git a/pkg/connector/user_token.go b/pkg/connector/user_token.go index ed189c2c..93c8b30e 100644 --- a/pkg/connector/user_token.go +++ b/pkg/connector/user_token.go @@ -3,6 +3,10 @@ package connector import ( "context" "fmt" + "strings" + + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" @@ -21,11 +25,11 @@ func newUserTokenResource(userService *admin.Service) *userTokenResource { return &userTokenResource{userService: userService} } -func (u userTokenResource) ResourceType(ctx context.Context) *v2.ResourceType { +func (u *userTokenResource) ResourceType(ctx context.Context) *v2.ResourceType { return resourceTypeUserToken } -func (u userTokenResource) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (u *userTokenResource) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { l := ctxzap.Extract(ctx) if parentResourceID == nil { @@ -46,10 +50,22 @@ func (u userTokenResource) List(ctx context.Context, parentResourceID *v2.Resour rv := make([]*v2.Resource, 0, len(doResponse.Items)) for _, token := range doResponse.Items { - rs, err := sdkResource.NewResource( + profile := map[string]any{ + "client_id": token.ClientId, + "display_text": token.DisplayText, + "scopes": strings.Join(token.Scopes, " "), + "user_key": token.UserKey, + } + + opts := []sdkResource.AppTraitOption{ + sdkResource.WithAppProfile(profile), + } + + rs, err := sdkResource.NewAppResource( token.DisplayText, resourceTypeUserToken, fmt.Sprintf("%s/%s", token.UserKey, token.ClientId), + opts, sdkResource.WithParentResourceID(parentResourceID), ) @@ -64,10 +80,58 @@ func (u userTokenResource) List(ctx context.Context, parentResourceID *v2.Resour return rv, "", nil, nil } -func (u userTokenResource) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - return nil, "", nil, nil +func (u *userTokenResource) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + "has", + entitlement.WithDisplayName("Has Token"), + entitlement.WithDescription("User has a token for an application"), + entitlement.WithAnnotation(&v2.EntitlementImmutable{}), + entitlement.WithGrantableTo(resourceTypeUser), + ), + }, "", nil, nil +} + +func (u *userTokenResource) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + idSplit := strings.Split(resource.Id.Resource, "/") + if len(idSplit) != 2 { + return nil, "", nil, fmt.Errorf("invalid resource id: %s", resource.Id.Resource) + } + + userKey := idSplit[0] + + grants := []*v2.Grant{ + grant.NewGrant(resource, "has", &v2.ResourceId{ + Resource: userKey, + ResourceType: resourceTypeUser.Id, + }), + } + + return grants, "", nil, nil +} + +func (u *userTokenResource) Grant(ctx context.Context, resource *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + return nil, nil, fmt.Errorf("granting user tokens is not supported, only revoking") } -func (u userTokenResource) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { - return nil, "", nil, nil +func (u *userTokenResource) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + if grant.Principal.Id.ResourceType != resourceTypeUser.Id { + return nil, fmt.Errorf("invalid grant type: %s", grant.Principal.Id.ResourceType) + } + + idSplit := strings.Split(grant.Entitlement.Resource.Id.Resource, "/") + if len(idSplit) != 2 { + return nil, fmt.Errorf("invalid resource id: %s", grant.Entitlement.Resource.Id.Resource) + } + + userKey := idSplit[0] + clientID := idSplit[1] + + err := u.userService.Tokens.Delete(userKey, clientID).Context(ctx).Do() + if err != nil { + return nil, err + } + + return nil, nil }