diff --git a/go.mod b/go.mod index ff36477d..5957a2c4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/conductorone/baton-github go 1.25.2 require ( - github.com/conductorone/baton-sdk v0.7.14 + github.com/conductorone/baton-sdk v0.7.12 github.com/deckarep/golang-set/v2 v2.8.0 github.com/ennyjfrick/ruleguard-logfatal v0.0.2 github.com/golang-jwt/jwt/v5 v5.2.2 diff --git a/go.sum b/go.sum index aeab47b7..9f27ec62 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/conductorone/baton-sdk v0.7.14 h1:EwA4LgCOnxVCVfly4Jx2M9sAn9MI5VUYmNcguISudbE= -github.com/conductorone/baton-sdk v0.7.14/go.mod h1:agmFrml6APUw4ZlqMEBrnXYj3aAOGKOJ6gztiNj64h0= +github.com/conductorone/baton-sdk v0.7.12 h1:LU9MZaYoxVZyAWMtTJE/i74FKp5Xp1kX2s7p0iBbrog= +github.com/conductorone/baton-sdk v0.7.12/go.mod h1:agmFrml6APUw4ZlqMEBrnXYj3aAOGKOJ6gztiNj64h0= github.com/conductorone/dpop v0.2.3 h1:s91U3845GHQ6P6FWrdNr2SEOy1ES/jcFs1JtKSl2S+o= github.com/conductorone/dpop v0.2.3/go.mod h1:gyo8TtzB9SCFCsjsICH4IaLZ7y64CcrDXMOPBwfq/3s= github.com/conductorone/dpop/integrations/dpop_grpc v0.2.3 h1:kLMCNIh0Mo2vbvvkCmJ3ixsPbXEJ6HPcW53Ku9yje3s= diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index d6d0b78c..7b3c4536 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -1,17 +1,17 @@ // Code generated by baton-sdk. DO NOT EDIT!!! package config -import "reflect" +import "reflect" type Github struct { - Token string `mapstructure:"token"` - Orgs []string `mapstructure:"orgs"` - Enterprises []string `mapstructure:"enterprises"` - InstanceUrl string `mapstructure:"instance-url"` - SyncSecrets bool `mapstructure:"sync-secrets"` - OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` - AppId string `mapstructure:"app-id"` - AppPrivatekeyPath string `mapstructure:"app-privatekey-path"` + Token string `mapstructure:"token"` + Orgs []string `mapstructure:"orgs"` + Enterprises []string `mapstructure:"enterprises"` + InstanceUrl string `mapstructure:"instance-url"` + SyncSecrets bool `mapstructure:"sync-secrets"` + OmitArchivedRepositories bool `mapstructure:"omit-archived-repositories"` + AppId string `mapstructure:"app-id"` + AppPrivatekeyPath string `mapstructure:"app-privatekey-path"` } func (c *Github) findFieldByTag(tagValue string) (any, bool) { @@ -46,11 +46,13 @@ func (c *Github) GetString(fieldName string) string { if !ok { return "" } - t, ok := v.(string) - if !ok { - panic("wrong type") + if t, ok := v.(string); ok { + return t } - return t + if t, ok := v.([]byte); ok { + return string(t) + } + panic("wrong type") } func (c *Github) GetInt(fieldName string) int { diff --git a/pkg/connector/repository.go b/pkg/connector/repository.go index 550acd4d..8f23b7be 100644 --- a/pkg/connector/repository.go +++ b/pkg/connector/repository.go @@ -7,7 +7,9 @@ import ( "strconv" "strings" + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/actions" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/entitlement" @@ -18,6 +20,7 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/structpb" ) // outside collaborators are given one of these roles too. @@ -433,3 +436,264 @@ func skipGrantsForResourceType(bag *pagination.Bag) (string, error) { } return pageToken, nil } + +// ResourceActions registers the resource actions for the repository resource type. +// This implements the ResourceActionProvider interface. +func (o *repositoryResourceType) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error { + if err := o.registerCreateRepositoryAction(ctx, registry); err != nil { + return err + } + return nil +} + +func (o *repositoryResourceType) registerCreateRepositoryAction(ctx context.Context, registry actions.ActionRegistry) error { + return registry.Register(ctx, &v2.BatonActionSchema{ + Name: "create", + DisplayName: "Create Repository", + Description: "Create a new repository in a GitHub organization", + ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_RESOURCE_CREATE}, + Arguments: []*config.Field{ + { + Name: "name", + DisplayName: "Repository name", + Description: "The name of the repository to create", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + { + Name: "description", + DisplayName: "Description", + Description: "A description of the repository", + Field: &config.Field_StringField{}, + }, + { + Name: "org", + DisplayName: "Organization", + Description: "The organization to create the repository in", + Field: &config.Field_ResourceIdField{ + ResourceIdField: &config.ResourceIdField{ + Rules: &config.ResourceIDRules{ + AllowedResourceTypeIds: []string{resourceTypeOrg.Id}, + }, + }, + }, + IsRequired: true, + }, + { + Name: "visibility", + DisplayName: "Visibility", + Description: "The visibility level of the repository", + Field: &config.Field_StringField{ + StringField: &config.StringField{ + Options: []*config.StringFieldOption{ + {Value: "public", DisplayName: "Public", Name: "Anyone on the internet can view this repository"}, + {Value: "private", DisplayName: "Private", Name: "You can choose who can see this repository"}, + {Value: "internal", DisplayName: "Internal", Name: "Members of the enterprise can view this repository (enterprise only)"}, + }, + DefaultValue: "private", + }, + }, + }, + { + Name: "add_readme", + DisplayName: "Add README.md", + Description: "Add a README.md file to the repository", + Field: &config.Field_BoolField{ + BoolField: &config.BoolField{ + DefaultValue: true, + }, + }, + }, + { + Name: "gitignore_template", + DisplayName: "Gitignore Template", + Description: "Gitignore template to apply", + Field: &config.Field_StringField{ + StringField: &config.StringField{ + Options: []*config.StringFieldOption{ + {Value: "", DisplayName: "No .gitignore template"}, + {Value: "Go", DisplayName: "Go"}, + {Value: "Python", DisplayName: "Python"}, + {Value: "Node", DisplayName: "Node"}, + {Value: "Java", DisplayName: "Java"}, + {Value: "Ruby", DisplayName: "Ruby"}, + {Value: "Rust", DisplayName: "Rust"}, + {Value: "C++", DisplayName: "C++"}, + {Value: "C", DisplayName: "C"}, + {Value: "Swift", DisplayName: "Swift"}, + {Value: "Kotlin", DisplayName: "Kotlin"}, + {Value: "Scala", DisplayName: "Scala"}, + {Value: "Terraform", DisplayName: "Terraform"}, + }, + }, + }, + }, + { + Name: "license_template", + DisplayName: "License Template", + Description: "License template to apply", + Field: &config.Field_StringField{ + StringField: &config.StringField{ + Options: []*config.StringFieldOption{ + {Value: "", DisplayName: "No license"}, + {Value: "mit", DisplayName: "MIT License"}, + {Value: "apache-2.0", DisplayName: "Apache License 2.0"}, + {Value: "gpl-3.0", DisplayName: "GNU GPLv3"}, + {Value: "gpl-2.0", DisplayName: "GNU GPLv2"}, + {Value: "lgpl-3.0", DisplayName: "GNU LGPLv3"}, + {Value: "bsd-3-clause", DisplayName: "BSD 3-Clause"}, + {Value: "bsd-2-clause", DisplayName: "BSD 2-Clause"}, + {Value: "mpl-2.0", DisplayName: "Mozilla Public License 2.0"}, + {Value: "unlicense", DisplayName: "The Unlicense"}, + {Value: "agpl-3.0", DisplayName: "GNU AGPLv3"}, + }, + }, + }, + }, + }, + ReturnTypes: []*config.Field{ + {Name: "success", Field: &config.Field_BoolField{}}, + {Name: "resource", Field: &config.Field_ResourceField{}}, + {Name: "entitlements", DisplayName: "Entitlements", Field: &config.Field_EntitlementSliceField{ + EntitlementSliceField: &config.EntitlementSliceField{}, + }}, + {Name: "grants", DisplayName: "Grants", Field: &config.Field_GrantSliceField{ + GrantSliceField: &config.GrantSliceField{}, + }}, + }, + }, o.handleCreateRepositoryAction) +} + +func (o *repositoryResourceType) handleCreateRepositoryAction(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + // Extract required arguments using SDK helpers + name, err := actions.RequireStringArg(args, "name") + if err != nil { + return nil, nil, err + } + + parentResourceID, err := actions.RequireResourceIDArg(args, "org") + if err != nil { + return nil, nil, err + } + + // Get the organization name from the parent resource ID + orgName, err := o.orgCache.GetOrgName(ctx, parentResourceID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get organization name: %w", err) + } + + l.Info("github-connector: creating repository via action", + zap.String("repo_name", name), + zap.String("org_name", orgName), + ) + + // Build the Repository request + newRepo := &github.Repository{ + Name: github.Ptr(name), + } + + // Extract optional fields using SDK helpers + if description, ok := actions.GetStringArg(args, "description"); ok && description != "" { + newRepo.Description = github.Ptr(description) + } + + if visibility, ok := actions.GetStringArg(args, "visibility"); ok && visibility != "" { + if visibility == "public" || visibility == "private" || visibility == "internal" { + newRepo.Visibility = github.Ptr(visibility) + } else { + return nil, nil, fmt.Errorf("invalid visibility: %q (must be \"public\", \"private\", or \"internal\")", visibility) + } + } + + // Extract template options first to validate AutoInit requirements + gitignoreTemplate, hasGitignore := actions.GetStringArg(args, "gitignore_template") + licenseTemplate, hasLicense := actions.GetStringArg(args, "license_template") + hasTemplates := (hasGitignore && gitignoreTemplate != "") || (hasLicense && licenseTemplate != "") + + // add_readme maps to AutoInit in GitHub API + // GitHub requires AutoInit=true when using gitignore_template or license_template + if addReadme, ok := actions.GetBoolArg(args, "add_readme"); ok { + if !addReadme && hasTemplates { + return nil, nil, fmt.Errorf("add_readme must be true when gitignore_template or license_template is provided (GitHub requires auto_init=true for templates)") + } + newRepo.AutoInit = github.Ptr(addReadme) + } else if hasTemplates { + // If templates are provided but add_readme wasn't explicitly set, enable AutoInit + newRepo.AutoInit = github.Ptr(true) + } + + if hasGitignore && gitignoreTemplate != "" { + newRepo.GitignoreTemplate = github.Ptr(gitignoreTemplate) + } + + if hasLicense && licenseTemplate != "" { + newRepo.LicenseTemplate = github.Ptr(licenseTemplate) + } + + // Create the repository via GitHub API + createdRepo, resp, err := o.client.Repositories.Create(ctx, orgName, newRepo) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to create repository %s in org %s", name, orgName)) + } + + // Extract rate limit data for annotations + var annos annotations.Annotations + if rateLimitData, err := extractRateLimitData(resp); err == nil { + annos.WithRateLimiting(rateLimitData) + } + + l.Info("github-connector: repository created successfully via action", + zap.String("repo_name", createdRepo.GetName()), + zap.Int64("repo_id", createdRepo.GetID()), + zap.String("repo_full_name", createdRepo.GetFullName()), + ) + + // Create the resource representation of the newly created repository + repoResource, err := repositoryResource(ctx, createdRepo, parentResourceID) + if err != nil { + return nil, annos, fmt.Errorf("failed to create resource representation: %w", err) + } + + // Generate entitlements for the newly created repository (reuse existing method) + entitlements, _, _, err := o.Entitlements(ctx, repoResource, nil) + if err != nil { + return nil, annos, fmt.Errorf("failed to generate entitlements: %w", err) + } + + // Fetch grants for the newly created repository by reusing the existing Grants method + var grants []*v2.Grant + pageToken := "" + for { + pToken := &pagination.Token{Token: pageToken} + pageGrants, nextToken, _, err := o.Grants(ctx, repoResource, pToken) + if err != nil { + l.Warn("github-connector: failed to fetch grants for repository", zap.Error(err)) + break + } + grants = append(grants, pageGrants...) + if nextToken == "" { + break + } + pageToken = nextToken + } + + // Build return values using SDK helpers + resourceRv, err := actions.NewResourceReturnField("resource", repoResource) + if err != nil { + return nil, annos, err + } + + entitlementsRv, err := actions.NewEntitlementListReturnField("entitlements", entitlements) + if err != nil { + return nil, annos, err + } + + grantsRv, err := actions.NewGrantListReturnField("grants", grants) + if err != nil { + return nil, annos, err + } + + return actions.NewReturnValues(true, resourceRv, entitlementsRv, grantsRv), annos, nil +} diff --git a/pkg/connector/repository_test.go b/pkg/connector/repository_test.go index 5c6ab7bc..9e955009 100644 --- a/pkg/connector/repository_test.go +++ b/pkg/connector/repository_test.go @@ -9,6 +9,7 @@ import ( entitlement2 "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/google/go-github/v69/github" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" "github.com/conductorone/baton-github/test" "github.com/conductorone/baton-github/test/mocks" @@ -76,3 +77,171 @@ func TestRepository(t *testing.T) { require.Empty(t, revokeAnnotations) }) } + +func TestRepositoryActions(t *testing.T) { + ctx := context.Background() + + t.Run("should create a basic repository with name, description and optional fields", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + githubOrganization, _, _, _, _, _ := mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + // Create args for the action + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-repo-basic", + "description": "A test repository for unit testing", + "visibility": "private", + "add_readme": true, + "gitignore_template": "Node", + "license_template": "apache-2.0", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", // Matches the seeded org ID + }, + }) + require.NoError(t, err) + + result, annos, err := client.handleCreateRepositoryAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, annos) + + // Verify success field + successVal := result.Fields["success"] + require.NotNil(t, successVal) + require.True(t, successVal.GetBoolValue()) + + // Verify resource was returned + resourceVal := result.Fields["resource"] + require.NotNil(t, resourceVal) + + _ = githubOrganization // Used in seed + }) + + t.Run("should create a public repository", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-repo-public", + "description": "A public test repository", + "visibility": "public", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateRepositoryAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Fields["success"].GetBoolValue()) + }) + + t.Run("should fail when templates are used but add_readme is false", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-repo-template-no-readme", + "description": "This should fail", + "visibility": "private", + "add_readme": false, // Explicitly false with templates + "gitignore_template": "Python", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateRepositoryAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "add_readme must be true") + }) + + t.Run("should fail with missing required name field", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + // Missing name field + args, err := structpb.NewStruct(map[string]interface{}{ + "description": "A repo without a name", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateRepositoryAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + }) + + t.Run("should fail with invalid visibility value", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-repo-invalid-visibility", + "visibility": "invalid_visibility_value", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateRepositoryAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "invalid visibility") + }) + + t.Run("should create internal repository (enterprise feature)", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := repositoryBuilder(githubClient, cache, false) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-repo-internal", + "description": "An internal repository", + "visibility": "internal", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateRepositoryAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Fields["success"].GetBoolValue()) + }) +} diff --git a/pkg/connector/team.go b/pkg/connector/team.go index 194b0ba2..7d307c42 100644 --- a/pkg/connector/team.go +++ b/pkg/connector/team.go @@ -6,7 +6,9 @@ import ( "strconv" "strings" + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/actions" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/entitlement" @@ -17,11 +19,15 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/types/known/structpb" ) const ( teamRoleMember = "member" teamRoleMaintainer = "maintainer" + + teamPrivacySecret = "secret" + teamPrivacyClosed = "closed" ) var teamAccessLevels = []string{ @@ -362,6 +368,315 @@ func (o *teamResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, nil } +// ResourceActions registers the resource actions for the team resource type. +// This implements the ResourceActionProvider interface. +func (o *teamResourceType) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error { + if err := o.registerCreateTeamAction(ctx, registry); err != nil { + return err + } + return nil +} + +func (o *teamResourceType) registerCreateTeamAction(ctx context.Context, registry actions.ActionRegistry) error { + return registry.Register(ctx, &v2.BatonActionSchema{ + Name: "create", + DisplayName: "Create Team", + Description: "Create a new team in a GitHub organization", + ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_RESOURCE_CREATE}, + Arguments: []*config.Field{ + { + Name: "name", + DisplayName: "Team name", + Description: "You’ll use this name to mention this team in conversations.", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + { + Name: "description", + DisplayName: "Description", + Description: "What is this team all about?", + Field: &config.Field_StringField{}, + }, + { + Name: "org", + DisplayName: "Organization", + Description: "The organization name.", + Field: &config.Field_ResourceIdField{ + ResourceIdField: &config.ResourceIdField{ + Rules: &config.ResourceIDRules{ + AllowedResourceTypeIds: []string{resourceTypeOrg.Id}, + }, + }, + }, + IsRequired: true, + }, + { + Name: "parent", + DisplayName: "Parent team", + Description: "The team to set as the parent team.", + Field: &config.Field_ResourceIdField{ + ResourceIdField: &config.ResourceIdField{ + Rules: &config.ResourceIDRules{ + AllowedResourceTypeIds: []string{resourceTypeTeam.Id}, + }, + }, + }, + }, + { + Name: "privacy", + DisplayName: "Privacy", + Description: "The level of privacy this team should have.", + Field: &config.Field_StringField{ + StringField: &config.StringField{ + Options: []*config.StringFieldOption{ + {Value: "secret", Name: "Secret is only visible to org owners and team members", DisplayName: "Secret"}, + {Value: "closed", Name: "Closed is visible to all org members. When parent team is set, this is the only allowed privacy level.", DisplayName: "Closed"}, + }, + DefaultValue: "closed", + }, + }, + }, + { + Name: "notifications_enabled", + DisplayName: "Team notifications", + Description: "When enabled, team members receive notifications when the team is @mentioned.", + Field: &config.Field_BoolField{ + BoolField: &config.BoolField{ + DefaultValue: true, + }, + }, + }, + { + Name: "maintainers", + DisplayName: "Team Maintainers", + Description: "List of user resource IDs for organization members who will become team maintainers.", + Field: &config.Field_ResourceIdSliceField{ + ResourceIdSliceField: &config.ResourceIdSliceField{ + Rules: &config.RepeatedResourceIdRules{ + AllowedResourceTypeIds: []string{resourceTypeUser.Id}, + }, + }, + }, + }, + { + Name: "repo_names", + DisplayName: "Repositories", + Description: "List of repository resource IDs to add the team to.", + Field: &config.Field_ResourceIdSliceField{ + ResourceIdSliceField: &config.ResourceIdSliceField{ + Rules: &config.RepeatedResourceIdRules{ + AllowedResourceTypeIds: []string{resourceTypeRepository.Id}, + }, + }, + }, + }, + }, + ReturnTypes: []*config.Field{ + {Name: "success", Field: &config.Field_BoolField{}}, + {Name: "resource", Field: &config.Field_ResourceField{}}, + {Name: "entitlements", DisplayName: "Entitlements", Field: &config.Field_EntitlementSliceField{ + EntitlementSliceField: &config.EntitlementSliceField{}, + }}, + {Name: "grants", DisplayName: "Grants", Field: &config.Field_GrantSliceField{ + GrantSliceField: &config.GrantSliceField{}, + }}, + }, + }, o.handleCreateTeamAction) +} + +func (o *teamResourceType) handleCreateTeamAction(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + // Extract required arguments using SDK helpers + name, err := actions.RequireStringArg(args, "name") + if err != nil { + return nil, nil, err + } + + parentResourceID, err := actions.RequireResourceIDArg(args, "org") + if err != nil { + return nil, nil, err + } + + // Get the organization name from the parent resource ID + orgName, err := o.orgCache.GetOrgName(ctx, parentResourceID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get organization name: %w", err) + } + + l.Info("github-connector: creating team via action", + zap.String("team_name", name), + zap.String("org_name", orgName), + ) + + // Build the NewTeam request + newTeam := github.NewTeam{ + Name: name, + } + + // Extract optional fields using SDK helpers + if description, ok := actions.GetStringArg(args, "description"); ok && description != "" { + newTeam.Description = github.Ptr(description) + } + + // Check if this is a nested team (has parent) + isNestedTeam := false + if parentTeamResourceID, ok := actions.GetResourceIDArg(args, "parent"); ok { + parentTeamID, err := strconv.ParseInt(parentTeamResourceID.Resource, 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid parent team ID: %w", err) + } + + // Fetch the parent team to validate it's not a secret team + // GitHub does not allow child teams under secret parent teams + org, resp, err := o.client.Organizations.Get(ctx, orgName) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to get organization %s", orgName)) + } + parentTeam, resp, err := o.client.Teams.GetTeamByID(ctx, org.GetID(), parentTeamID) //nolint:staticcheck // TODO: migrate to GetTeamBySlug + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to get parent team %d", parentTeamID)) + } + if parentTeam.GetPrivacy() == teamPrivacySecret { + return nil, nil, fmt.Errorf("cannot create child team: parent team %q has privacy set to \"secret\"; GitHub does not allow child teams under secret parent teams", parentTeam.GetName()) + } + + newTeam.ParentTeamID = github.Ptr(parentTeamID) + isNestedTeam = true + } + + // Handle privacy with constraints based on team type: + // - For non-nested teams: "secret" (default) or "closed" + // - For nested/child teams: only "closed" is allowed (default: closed) + if privacy, ok := actions.GetStringArg(args, "privacy"); ok && privacy != "" { + switch { + case isNestedTeam: + // Nested teams can only be "closed" + if privacy == teamPrivacySecret { + l.Warn("github-connector: secret privacy not allowed for nested teams, using closed", + zap.String("requested_privacy", privacy), + ) + } + newTeam.Privacy = github.Ptr(teamPrivacyClosed) + case privacy == teamPrivacySecret || privacy == teamPrivacyClosed: + // Non-nested teams can be "secret" or "closed" + newTeam.Privacy = github.Ptr(privacy) + default: + // Invalid privacy value for non-nested team + return nil, nil, fmt.Errorf("invalid privacy value: %q (must be \"secret\" or \"closed\")", privacy) + } + } else if isNestedTeam { + // Default for nested teams is "closed" + newTeam.Privacy = github.Ptr(teamPrivacyClosed) + } + // Note: Default for non-nested teams is "secret" (handled by GitHub API) + + if notificationsEnabled, ok := actions.GetBoolArg(args, "notifications_enabled"); ok { + if notificationsEnabled { + newTeam.NotificationSetting = github.Ptr("notifications_enabled") + } else { + newTeam.NotificationSetting = github.Ptr("notifications_disabled") + } + } + + if maintainerIDs, ok := actions.GetResourceIdListArg(args, "maintainers"); ok && len(maintainerIDs) > 0 { + var maintainerLogins []string + for _, rid := range maintainerIDs { + userID, err := strconv.ParseInt(rid.Resource, 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid maintainer user ID %s: %w", rid.Resource, err) + } + user, resp, err := o.client.Users.GetByID(ctx, userID) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to get user %d", userID)) + } + maintainerLogins = append(maintainerLogins, user.GetLogin()) + } + newTeam.Maintainers = maintainerLogins + } + + if repoIDs, ok := actions.GetResourceIdListArg(args, "repo_names"); ok && len(repoIDs) > 0 { + var repoFullNames []string + for _, rid := range repoIDs { + repoID, err := strconv.ParseInt(rid.Resource, 10, 64) + if err != nil { + return nil, nil, fmt.Errorf("invalid repository ID %s: %w", rid.Resource, err) + } + repo, resp, err := o.client.Repositories.GetByID(ctx, repoID) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to get repository %d", repoID)) + } + repoFullNames = append(repoFullNames, repo.GetFullName()) + } + newTeam.RepoNames = repoFullNames + } + + // Create the team via GitHub API + createdTeam, resp, err := o.client.Teams.CreateTeam(ctx, orgName, newTeam) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, fmt.Sprintf("failed to create team %s in org %s", name, orgName)) + } + + // Extract rate limit data for annotations + var annos annotations.Annotations + if rateLimitData, err := extractRateLimitData(resp); err == nil { + annos.WithRateLimiting(rateLimitData) + } + + l.Info("github-connector: team created successfully via action", + zap.String("team_name", createdTeam.GetName()), + zap.Int64("team_id", createdTeam.GetID()), + zap.String("team_slug", createdTeam.GetSlug()), + ) + + // Create the resource representation of the newly created team + teamRes, err := teamResource(createdTeam, parentResourceID) + if err != nil { + return nil, annos, fmt.Errorf("failed to create resource representation: %w", err) + } + + // Generate entitlements for the newly created team (reuse existing method) + entitlements, _, _, err := o.Entitlements(ctx, teamRes, nil) + if err != nil { + return nil, annos, fmt.Errorf("failed to generate entitlements: %w", err) + } + + // Fetch grants for the newly created team by reusing the existing Grants method + var grants []*v2.Grant + pageToken := "" + for { + pToken := &pagination.Token{Token: pageToken} + pageGrants, nextToken, _, err := o.Grants(ctx, teamRes, pToken) + if err != nil { + l.Warn("github-connector: failed to fetch grants for team", zap.Error(err)) + break + } + grants = append(grants, pageGrants...) + if nextToken == "" { + break + } + pageToken = nextToken + } + + // Build return values using SDK helpers + resourceRv, err := actions.NewResourceReturnField("resource", teamRes) + if err != nil { + return nil, annos, err + } + + entitlementsRv, err := actions.NewEntitlementListReturnField("entitlements", entitlements) + if err != nil { + return nil, annos, err + } + + grantsRv, err := actions.NewGrantListReturnField("grants", grants) + if err != nil { + return nil, annos, err + } + + return actions.NewReturnValues(true, resourceRv, entitlementsRv, grantsRv), annos, nil +} + func teamBuilder(client *github.Client, orgCache *orgNameCache) *teamResourceType { return &teamResourceType{ resourceType: resourceTypeTeam, diff --git a/pkg/connector/team_test.go b/pkg/connector/team_test.go index d739b529..03ad5579 100644 --- a/pkg/connector/team_test.go +++ b/pkg/connector/team_test.go @@ -9,6 +9,7 @@ import ( entitlement2 "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/google/go-github/v69/github" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" "github.com/conductorone/baton-github/test" "github.com/conductorone/baton-github/test/mocks" @@ -54,3 +55,213 @@ func TestTeam(t *testing.T) { require.Empty(t, revokeAnnotations) }) } + +func TestTeamActions(t *testing.T) { + ctx := context.Background() + + t.Run("should create a basic team with name, description, notifications, and privacy", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + githubOrganization, _, _, _, _, _ := mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + // Create args for the action + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-team-basic", + "description": "A test team for unit testing", + "privacy": "secret", + "notifications_enabled": true, + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", // Matches the seeded org ID + }, + }) + require.NoError(t, err) + + result, annos, err := client.handleCreateTeamAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, annos) + + // Verify success field + successVal := result.Fields["success"] + require.NotNil(t, successVal) + require.True(t, successVal.GetBoolValue()) + + // Verify resource was returned + resourceVal := result.Fields["resource"] + require.NotNil(t, resourceVal) + + _ = githubOrganization // Used in seed + }) + + t.Run("should create a team with multiple maintainers", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, existingUser, _, _ := mgh.Seed() + + // Add a second user + secondUserID := int64(100) + secondUserLogin := "100" + secondUserEmail := "seconduser@example.com" + mgh.AddUser(github.User{ + ID: github.Ptr(secondUserID), + Login: github.Ptr(secondUserLogin), + Email: github.Ptr(secondUserEmail), + }) + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-team-maintainers", + "description": "Team with multiple maintainers", + "privacy": "secret", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + "maintainers": []interface{}{ + map[string]interface{}{ + "resource_type": "user", + "resource": "56", // existingUser.ID + }, + map[string]interface{}{ + "resource_type": "user", + "resource": "100", // secondUserID + }, + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateTeamAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Fields["success"].GetBoolValue()) + + _ = existingUser // Used in seed + }) + + t.Run("should fail to create nested team when parent team is secret", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + githubOrganization, _, _, _, _, _ := mgh.Seed() + + // Add a secret parent team + secretParentTeamID := int64(200) + mgh.AddTeam(github.Team{ + ID: github.Ptr(secretParentTeamID), + Name: github.Ptr("secret-parent-team"), + Slug: github.Ptr("secret-parent-team"), + Organization: githubOrganization, + Privacy: github.Ptr("secret"), + }) + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "nested-team-under-secret", + "description": "This should fail because parent is secret", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + "parent": map[string]interface{}{ + "resource_type": "team", + "resource": "200", // Secret parent team ID + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateTeamAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "cannot create child team") + require.Contains(t, err.Error(), "secret") + }) + + t.Run("should successfully create nested team when parent team is closed", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + githubOrganization, _, _, _, _, _ := mgh.Seed() + + // Add a closed parent team + closedParentTeamID := int64(201) + mgh.AddTeam(github.Team{ + ID: github.Ptr(closedParentTeamID), + Name: github.Ptr("closed-parent-team"), + Slug: github.Ptr("closed-parent-team"), + Organization: githubOrganization, + Privacy: github.Ptr("closed"), + }) + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "nested-team-under-closed", + "description": "Nested team under closed parent", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + "parent": map[string]interface{}{ + "resource_type": "team", + "resource": "201", // Closed parent team ID + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateTeamAction(ctx, args) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Fields["success"].GetBoolValue()) + }) + + t.Run("should fail with missing required org field", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + // Missing org field + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-team", + "description": "A team without org", + }) + require.NoError(t, err) + + result, _, err := client.handleCreateTeamAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + }) + + t.Run("should fail with invalid privacy value", func(t *testing.T) { + mgh := mocks.NewMockGitHub() + _, _, _, _, _, _ = mgh.Seed() + + githubClient := github.NewClient(mgh.Server()) + cache := newOrgNameCache(githubClient) + client := teamBuilder(githubClient, cache) + + args, err := structpb.NewStruct(map[string]interface{}{ + "name": "test-team-invalid-privacy", + "privacy": "invalid_privacy_value", + "org": map[string]interface{}{ + "resource_type": "org", + "resource": "12", + }, + }) + require.NoError(t, err) + + result, _, err := client.handleCreateTeamAction(ctx, args) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "invalid privacy value") + }) +} diff --git a/test/mocks/endpointpattern.go b/test/mocks/endpointpattern.go index c57cefff..2ac131a1 100644 --- a/test/mocks/endpointpattern.go +++ b/test/mocks/endpointpattern.go @@ -67,3 +67,21 @@ var DeleteOrgsRolesUsersByOrgByRoleIdByUsername = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles/users/{username}/{role_id}", Method: "DELETE", } + +// Team creation endpoint. +var PostOrgsTeamsByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/teams", + Method: "POST", +} + +// Repository creation endpoint. +var PostOrgsReposByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/repos", + Method: "POST", +} + +// Get organization by slug/login (not numeric ID). +var GetOrgsByName = mock.EndpointPattern{ + Pattern: "/orgs/{org}", + Method: "GET", +} diff --git a/test/mocks/github.go b/test/mocks/github.go index 45f5445a..6b455e81 100644 --- a/test/mocks/github.go +++ b/test/mocks/github.go @@ -753,6 +753,10 @@ func (mgh MockGitHub) Server() *http.Client { }: mgh.getOrgRoleByID, PutOrgsRolesUsersByOrgByRoleIdByUsername: mgh.addOrgRoleUser, DeleteOrgsRolesUsersByOrgByRoleIdByUsername: mgh.removeOrgRoleUser, + // Team and repository creation + PostOrgsTeamsByOrg: mgh.createTeam, + PostOrgsReposByOrg: mgh.createRepository, + GetOrgsByName: mgh.getOrganizationBySlug, } options := make([]mock.MockBackendOption, 0) @@ -782,3 +786,177 @@ func (mgh *MockGitHub) AddMembership(teamID int64, userID int64) { } mgh.teamMemberships[teamID].Add(userID) } + +// AddUser adds a user to the mock server for testing purposes. +func (mgh *MockGitHub) AddUser(user github.User) { + mgh.users[*user.ID] = user +} + +// AddRepository adds a repository to the mock server for testing purposes. +func (mgh *MockGitHub) AddRepository(repo github.Repository) { + mgh.repositories[*repo.ID] = repo +} + +// AddOrganization adds an organization to the mock server for testing purposes. +func (mgh *MockGitHub) AddOrganization(org github.Organization) { + mgh.organizations[*org.ID] = org +} + +// nextTeamID tracks the next team ID to assign for created teams. +var nextTeamID int64 = 1000 + +// nextRepoID tracks the next repo ID to assign for created repos. +var nextRepoID int64 = 1000 + +// createTeam creates a new team in the mock server. +func (mgh *MockGitHub) createTeam( + w http.ResponseWriter, + variables map[string]string, +) { + orgSlug, ok := variables["org"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Find org by slug + var foundOrg *github.Organization + for _, org := range mgh.organizations { + if org.GetLogin() == orgSlug { + foundOrg = &org + break + } + } + if foundOrg == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + teamName := variables["name"] + if teamName == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Get privacy setting + privacy := variables["privacy"] + if privacy == "" { + privacy = "secret" // Default for non-nested teams + } + + // Check if parent team is specified + parentTeamIDStr := variables["parent_team_id"] + var parentTeam *github.Team + if parentTeamIDStr != "" { + parentTeamID, err := strconv.ParseInt(parentTeamIDStr, 10, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + pt, ok := mgh.teams[parentTeamID] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + parentTeam = &pt + // Nested teams must be closed + privacy = "closed" + } + + teamID := nextTeamID + nextTeamID++ + + newTeam := github.Team{ + ID: github.Ptr(teamID), + Name: github.Ptr(teamName), + Slug: github.Ptr(strings.ToLower(strings.ReplaceAll(teamName, " ", "-"))), + Organization: foundOrg, + Privacy: github.Ptr(privacy), + } + if parentTeam != nil { + newTeam.Parent = parentTeam + } + + mgh.teams[teamID] = newTeam + mgh.teamMemberships[teamID] = mapset.NewSet[int64]() + + _, _ = w.Write(mock.MustMarshal(newTeam)) +} + +// createRepository creates a new repository in the mock server. +func (mgh *MockGitHub) createRepository( + w http.ResponseWriter, + variables map[string]string, +) { + orgSlug, ok := variables["org"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Find org by slug + var foundOrg *github.Organization + for _, org := range mgh.organizations { + if org.GetLogin() == orgSlug { + foundOrg = &org + break + } + } + if foundOrg == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + repoName := variables["name"] + if repoName == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + visibility := variables["visibility"] + if visibility == "" { + visibility = "private" + } + + description := variables["description"] + + repoID := nextRepoID + nextRepoID++ + + fullName := fmt.Sprintf("%s/%s", foundOrg.GetLogin(), repoName) + newRepo := github.Repository{ + ID: github.Ptr(repoID), + Name: github.Ptr(repoName), + FullName: github.Ptr(fullName), + Description: github.Ptr(description), + Organization: foundOrg, + Visibility: github.Ptr(visibility), + Private: github.Ptr(visibility == "private"), + } + + mgh.repositories[repoID] = newRepo + mgh.repositoryMemberships[repoID] = mapset.NewSet[int64]() + + _, _ = w.Write(mock.MustMarshal(newRepo)) +} + +// getOrganizationBySlug gets an organization by its slug/login. +func (mgh *MockGitHub) getOrganizationBySlug( + w http.ResponseWriter, + variables map[string]string, +) { + orgSlug, ok := variables["org"] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + // Find org by slug + for _, org := range mgh.organizations { + if org.GetLogin() == orgSlug { + _, _ = w.Write(mock.MustMarshal(org)) + return + } + } + w.WriteHeader(http.StatusNotFound) +} diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight.pb.go index 355888bc..354f27af 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight.pb.go @@ -28,8 +28,7 @@ const ( type RiskScore struct { state protoimpl.MessageState `protogen:"hybrid.v1"` // The risk score value (e.g., "85", "High") - Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` - Factors []string `protobuf:"bytes,2,rep,name=factors,proto3" json:"factors,omitempty"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -66,27 +65,15 @@ func (x *RiskScore) GetValue() string { return "" } -func (x *RiskScore) GetFactors() []string { - if x != nil { - return x.Factors - } - return nil -} - func (x *RiskScore) SetValue(v string) { x.Value = v } -func (x *RiskScore) SetFactors(v []string) { - x.Factors = v -} - type RiskScore_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // The risk score value (e.g., "85", "High") - Value string - Factors []string + Value string } func (b0 RiskScore_builder) Build() *RiskScore { @@ -94,7 +81,6 @@ func (b0 RiskScore_builder) Build() *RiskScore { b, x := &b0, m0 _, _ = b, x x.Value = b.Value - x.Factors = b.Factors return m0 } @@ -842,10 +828,9 @@ var File_c1_connector_v2_annotation_security_insight_proto protoreflect.FileDesc const file_c1_connector_v2_annotation_security_insight_proto_rawDesc = "" + "\n" + - "1c1/connector/v2/annotation_security_insight.proto\x12\x0fc1.connector.v2\x1a\x1ec1/connector/v2/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\";\n" + + "1c1/connector/v2/annotation_security_insight.proto\x12\x0fc1.connector.v2\x1a\x1ec1/connector/v2/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\"!\n" + "\tRiskScore\x12\x14\n" + - "\x05value\x18\x01 \x01(\tR\x05value\x12\x18\n" + - "\afactors\x18\x02 \x03(\tR\afactors\"E\n" + + "\x05value\x18\x01 \x01(\tR\x05value\"E\n" + "\x05Issue\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12&\n" + "\bseverity\x18\x02 \x01(\tB\n" + diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight_protoopaque.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight_protoopaque.pb.go index e3e03b5a..394575c8 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight_protoopaque.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/annotation_security_insight_protoopaque.pb.go @@ -26,11 +26,10 @@ const ( // RiskScore represents a risk score insight type RiskScore struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Value string `protobuf:"bytes,1,opt,name=value,proto3"` - xxx_hidden_Factors []string `protobuf:"bytes,2,rep,name=factors,proto3"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Value string `protobuf:"bytes,1,opt,name=value,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RiskScore) Reset() { @@ -65,27 +64,15 @@ func (x *RiskScore) GetValue() string { return "" } -func (x *RiskScore) GetFactors() []string { - if x != nil { - return x.xxx_hidden_Factors - } - return nil -} - func (x *RiskScore) SetValue(v string) { x.xxx_hidden_Value = v } -func (x *RiskScore) SetFactors(v []string) { - x.xxx_hidden_Factors = v -} - type RiskScore_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // The risk score value (e.g., "85", "High") - Value string - Factors []string + Value string } func (b0 RiskScore_builder) Build() *RiskScore { @@ -93,7 +80,6 @@ func (b0 RiskScore_builder) Build() *RiskScore { b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Value = b.Value - x.xxx_hidden_Factors = b.Factors return m0 } @@ -808,10 +794,9 @@ var File_c1_connector_v2_annotation_security_insight_proto protoreflect.FileDesc const file_c1_connector_v2_annotation_security_insight_proto_rawDesc = "" + "\n" + - "1c1/connector/v2/annotation_security_insight.proto\x12\x0fc1.connector.v2\x1a\x1ec1/connector/v2/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\";\n" + + "1c1/connector/v2/annotation_security_insight.proto\x12\x0fc1.connector.v2\x1a\x1ec1/connector/v2/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\"!\n" + "\tRiskScore\x12\x14\n" + - "\x05value\x18\x01 \x01(\tR\x05value\x12\x18\n" + - "\afactors\x18\x02 \x03(\tR\afactors\"E\n" + + "\x05value\x18\x01 \x01(\tR\x05value\"E\n" + "\x05Issue\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12&\n" + "\bseverity\x18\x02 \x01(\tB\n" + diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/connectorrunner/runner.go b/vendor/github.com/conductorone/baton-sdk/pkg/connectorrunner/runner.go index 4c724a2c..2690bcdb 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/connectorrunner/runner.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/connectorrunner/runner.go @@ -8,7 +8,6 @@ import ( "os/signal" "path/filepath" "strings" - "sync" "time" "github.com/conductorone/baton-sdk/pkg/bid" @@ -40,103 +39,56 @@ const ( ) type connectorRunner struct { - cw types.ClientWrapper - oneShot bool - tasks tasks.Manager - debugFile *os.File - debugFileMutex sync.Mutex - healthServer *healthcheck.Server + cw types.ClientWrapper + oneShot bool + tasks tasks.Manager + debugFile *os.File + healthServer *healthcheck.Server } var ErrSigTerm = errors.New("context cancelled by process shutdown") -// setupPersistentLog ensures that a log file on disk is created, -// when required by either the stored Manager or by a Task. -// A log file created by a stored Manager persists for our entire run, -// while a log file created for a Task only lasts for that Task. -// (There is currently no good way for a Manager to require this.) -// -// This function always returns a valid context, even if -// a persistent log file could not be created. -func (c *connectorRunner) setupPersistentLog(ctx context.Context, requiredByTask bool) (context.Context, error) { - var err error - - // We lock around manipulation of the debug file field for safety, - // but make no attempt to serialize logging of concurrent tasks. - c.debugFileMutex.Lock() - defer c.debugFileMutex.Unlock() - +// Run starts a connector and creates a new C1Z file. +func (c *connectorRunner) Run(ctx context.Context) error { l := ctxzap.Extract(ctx) + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(ErrSigTerm) - requiredByManager := c.tasks.ShouldDebug() - if !requiredByTask && !requiredByManager { - // If we're not being required to create a persistent log by a task - // and our runner doesn't want one, we have nothing to do. - return ctx, nil - } else if c.debugFile != nil && requiredByManager { - // If a log file already exists from our runner, - // we also have nothing to do. - return ctx, nil - } + if c.tasks.ShouldDebug() && c.debugFile == nil { + var err error + tempDir := c.tasks.GetTempDir() + if tempDir == "" { + wd, err := os.Getwd() + if err != nil { + l.Warn("unable to get the current working directory", zap.Error(err)) + } - if c.debugFile != nil { - // A log file already exists from a previous task, and it is time to rotate it. - // The file is likely already closed, but we attempt to Close() it to be sure - // and rotate it by calling Create() on it below - this is equivalent to open(O_TRUNC). - l.Info("Rotating existing log file") - err = c.debugFile.Close() - if err != nil { - l.Warn("cannot close existing log file, continuing to rotate log...", zap.Error(err)) + if wd != "" { + l.Warn("no temporal folder found on this system according to our task manager,"+ + " we may create files in the current working directory by mistake as a result", + zap.String("current working directory", wd)) + } else { + l.Warn("no temporal folder found on this system according to our task manager") + } } - - c.debugFile = nil - } - - // Create/truncate the log file, and open it. - tempDir := c.tasks.GetTempDir() - if tempDir == "" { - wd, err := os.Getwd() + debugFile := filepath.Join(tempDir, "debug.log") + c.debugFile, err = os.Create(debugFile) if err != nil { - l.Warn("unable to get the current working directory", zap.Error(err)) - } - - if wd != "" { - l.Warn("no temporal folder found on this system according to our task manager,"+ - " we may create files in the current working directory by mistake as a result", - zap.String("current working directory", wd)) - } else { - l.Warn("no temporal folder found on this system according to our task manager") + l.Warn("cannot create file", zap.String("full file path", debugFile), zap.Error(err)) } } - debugFile := filepath.Join(tempDir, "debug.log") - c.debugFile, err = os.Create(debugFile) - if err != nil { - l.Warn("cannot create debug log file", zap.String("file_path", debugFile), zap.Error(err)) - return ctx, err - } + // modify the context to insert a logger directed to a file + if c.debugFile != nil { + writeSyncer := zapcore.AddSync(c.debugFile) + encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) + core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) - // Modify the context to insert a logger directed to that file. - writeSyncer := zapcore.AddSync(c.debugFile) - encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) - core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) + l = l.WithOptions(zap.WrapCore(func(c zapcore.Core) zapcore.Core { + return zapcore.NewTee(c, core) + })) - l = l.WithOptions(zap.WrapCore(func(c zapcore.Core) zapcore.Core { - return zapcore.NewTee(c, core) - })) - return ctxzap.ToContext(ctx, l), nil -} - -// Run starts a connector and creates a new C1Z file. -func (c *connectorRunner) Run(ctx context.Context) error { - ctx, cancel := context.WithCancelCause(ctx) - defer cancel(ErrSigTerm) - - var err error - ctx, err = c.setupPersistentLog(ctx, false) - if err != nil { - l := ctxzap.Extract(ctx) - l.Warn("Persistent logging could not be set up.", zap.Error(err)) + ctx = ctxzap.ToContext(ctx, l) } sigChan := make(chan os.Signal, 1) @@ -147,7 +99,7 @@ func (c *connectorRunner) Run(ctx context.Context) error { } }() - err = c.run(ctx) + err := c.run(ctx) if err != nil { return err } @@ -178,16 +130,6 @@ func (c *connectorRunner) processTask(ctx context.Context, task *v1.Task) error return fmt.Errorf("runner: error creating connector client: %w", err) } - // While we may not have already set up a persistent log file, - // if the task requires one, we set it up here. - if task.GetDebug() { - ctx, err = c.setupPersistentLog(ctx, true) - if err != nil { - l := ctxzap.Extract(ctx) - l.Warn("Persistent logging for this Task could not be set up.", zap.Error(err)) - } - } - err = c.tasks.Process(ctx, task, cc) if err != nil { return fmt.Errorf("runner: error processing task: %w", err) diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go index 3474d751..b75ca3bf 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go @@ -1,3 +1,3 @@ package sdk -const Version = "v0.7.13" +const Version = "v0.7.10" diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go index 3e236adc..4171ecc2 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go @@ -145,7 +145,6 @@ func (c *fullSyncTaskHandler) HandleTask(ctx context.Context) error { c1zPath := assetFile.Name() err = assetFile.Close() if err != nil { - l.Error("failed to close asset file", zap.Error(err)) return c.helpers.FinishTask(ctx, nil, nil, err) } @@ -184,9 +183,8 @@ func (c *fullSyncTaskHandler) HandleTask(ctx context.Context) error { return c.helpers.FinishTask(ctx, nil, nil, err) } - err = uploadDebugLogs(ctx, c.helpers, c.task.GetDebug()) + err = uploadDebugLogs(ctx, c.helpers) if err != nil { - l.Error("failed to upload debug Task logs", zap.Error(err)) return c.helpers.FinishTask(ctx, nil, nil, err) } @@ -213,9 +211,7 @@ func newFullSyncTaskHandler( } } -// Check if Debug logs should be uploaded to C1 and if so do so, -// otherwise silently return success. -func uploadDebugLogs(ctx context.Context, helper fullSyncHelpers, deleteDebugLogs bool) error { +func uploadDebugLogs(ctx context.Context, helper fullSyncHelpers) error { ctx, span := tracer.Start(ctx, "uploadDebugLogs") defer span.End() @@ -252,27 +248,19 @@ func uploadDebugLogs(ctx context.Context, helper fullSyncHelpers, deleteDebugLog debugfile, err := os.Open(debugPath) if err != nil { - l.Error("failed to open debug log file path", zap.Error(err)) return err } - if deleteDebugLogs { - // We only delete the debug log when asked to, - // as Manager-required log files are not automatically rotated. - defer func() { - err := os.Remove(debugPath) - if err != nil { - l.Error("failed to delete file with debug logs", zap.Error(err), zap.String("file", debugPath)) - } else { - l.Info("deleted debug path") - } - }() - } + defer func() { + err := os.Remove(debugPath) + if err != nil { + l.Error("failed to delete file with debug logs", zap.Error(err), zap.String("file", debugPath)) + } + }() defer debugfile.Close() l.Info("uploading debug logs", zap.String("file", debugPath)) err = helper.Upload(ctx, debugfile) if err != nil { - l.Error("failed to upload debug logs", zap.Error(err)) return err } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/service_client.go b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/service_client.go index 5fe8a32f..f7ef2c82 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/service_client.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/service_client.go @@ -183,14 +183,12 @@ func (c *c1ServiceClient) upload(ctx context.Context, task *v1.Task, r io.ReadSe client, done, err := c.getClientConn(ctx) if err != nil { - l.Error("failed to get client connection", zap.Error(err)) return err } defer done() uc, err := client.UploadAsset(ctx) if err != nil { - l.Error("UploadAsset returned error", zap.Error(err)) return err } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/security_insight_trait.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/security_insight_trait.go index f3079e19..4aec02f7 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/security_insight_trait.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/security_insight_trait.go @@ -26,20 +26,6 @@ func WithRiskScore(value string) SecurityInsightTraitOption { } } -// WithRiskScoreFactors sets or updates the factors on a risk score insight. -// Factors provide context as to why a particular risk score was assigned. -// This should be used after WithRiskScore or on an existing risk score insight. -func WithRiskScoreFactors(factors ...string) SecurityInsightTraitOption { - return func(t *v2.SecurityInsightTrait) error { - rs := t.GetRiskScore() - if rs == nil { - return fmt.Errorf("cannot set factors: insight is not a risk score type (use WithRiskScore first)") - } - rs.SetFactors(factors) - return nil - } -} - // WithIssue sets the insight type to issue with the given value. func WithIssue(value string) SecurityInsightTraitOption { return func(t *v2.SecurityInsightTrait) error { @@ -226,7 +212,6 @@ func WithSecurityInsightTrait(opts ...SecurityInsightTraitOption) ResourceOption // securityInsightResourceType, // "user-123", // WithRiskScore("85"), -// WithRiskScoreFactors("MFA not enabled", "No recent activity", "Excessive permissions"), // WithInsightUserTarget("user@example.com")) // // // Issue with severity for a resource @@ -301,14 +286,6 @@ func GetIssueSeverity(trait *v2.SecurityInsightTrait) string { return "" } -// GetRiskScoreFactors returns the factors of a risk score insight, or nil if not set or not a risk score. -func GetRiskScoreFactors(trait *v2.SecurityInsightTrait) []string { - if rs := trait.GetRiskScore(); rs != nil { - return rs.GetFactors() - } - return nil -} - // --- Target type checkers --- // IsUserTarget returns true if the insight targets a user. diff --git a/vendor/modules.txt b/vendor/modules.txt index ad9793bd..b3aeee38 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -162,7 +162,7 @@ github.com/cenkalti/backoff/v5 # github.com/cespare/xxhash/v2 v2.3.0 ## explicit; go 1.11 github.com/cespare/xxhash/v2 -# github.com/conductorone/baton-sdk v0.7.14 +# github.com/conductorone/baton-sdk v0.7.12 ## explicit; go 1.25.2 github.com/conductorone/baton-sdk/internal/connector github.com/conductorone/baton-sdk/pb/c1/c1z/v1