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/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 33e01f8f..c71b7840 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -79,6 +79,14 @@ var ( }, }, } + + resourceTypeUserToken = &v2.ResourceType{ + Id: "user_token", + DisplayName: "User Tokens", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_APP, + }, + } ) type Config struct { @@ -86,6 +94,7 @@ type Config struct { AdministratorEmail string Domain string Credentials []byte + SyncTokens bool } type GoogleWorkspace struct { @@ -102,6 +111,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) @@ -169,6 +179,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 } @@ -278,7 +289,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 @@ -296,6 +307,13 @@ func (c *GoogleWorkspace) ResourceSyncers(ctx context.Context) []connectorbuilde )) } } + + if c.syncTokens { + if userTokenService, err := c.getDirectoryService(ctx, directoryAdmin.AdminDirectoryUserSecurityScope); err == nil { + rs = append(rs, newUserTokenResource(userTokenService)) + } + } + return rs } diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 440c76d6..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,16 +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, }, ), + } + + 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 new file mode 100644 index 00000000..93c8b30e --- /dev/null +++ b/pkg/connector/user_token.go @@ -0,0 +1,137 @@ +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" + "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 { + 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), + ) + + 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 []*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) 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 +}