Skip to content
Open
86 changes: 86 additions & 0 deletions cmd/server/app/backfill_organizations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package app

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/rs/zerolog"

"github.com/mindersec/minder/internal/db"
"github.com/mindersec/minder/pkg/entities/properties"
)

func backfillOrganizations(ctx context.Context, store db.Store) error {
Comment thread
Jaydeep869 marked this conversation as resolved.
Outdated
l := zerolog.Ctx(ctx)
l.Info().Msg("Starting backfill for Organization entities...")

provs, err := store.GlobalListProvidersByClass(ctx, db.ProviderClassGithubApp)
if err != nil {
return fmt.Errorf("failed to list providers: %w", err)
}

count := 0

for _, prov := range provs {
login := strings.TrimPrefix(prov.Name, string(db.ProviderClassGithubApp)+"-")
Comment thread
Jaydeep869 marked this conversation as resolved.
Outdated

_, err = db.WithTransaction(store, func(qtx db.ExtendQuerier) (any, error) {
// Check if organization entity already exists
_, err := qtx.GetEntityByName(ctx, db.GetEntityByNameParams{
EntityType: db.EntitiesOrganization,
Name: login,
ProviderID: prov.ID,
ProjectID: prov.ProjectID,
})

if err == nil {
return nil, nil // already exists
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}

// Entity doesn't exist, create it
ent, err := qtx.CreateEntity(ctx, db.CreateEntityParams{
EntityType: db.EntitiesOrganization,
Name: login,
ProviderID: prov.ID,
ProjectID: prov.ProjectID,
})
if err != nil {
return nil, err
}

// Set the default property (login name)
propVal := map[string]any{
"minder.internal.type": "string",
"minder.internal.value": login,
}
propBytes, _ := json.Marshal(propVal)

_, err = qtx.UpsertProperty(ctx, db.UpsertPropertyParams{
EntityID: ent.ID,
Key: properties.PropertyName,
Value: propBytes,
})

if err == nil {
count++
}
return nil, err
})

if err != nil {
l.Error().Err(err).Str("provider", prov.ID.String()).Msg("Failed to backfill organization for provider")
}
}

l.Info().Int("count", count).Msg("Completed backfill for Organization entities")
return nil
}
6 changes: 6 additions & 0 deletions cmd/server/app/migrate_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/mindersec/minder/database"
"github.com/mindersec/minder/internal/authz"
"github.com/mindersec/minder/internal/db"
"github.com/mindersec/minder/pkg/config"
serverconfig "github.com/mindersec/minder/pkg/config/server"
)
Expand Down Expand Up @@ -98,6 +99,11 @@ var upCmd = &cobra.Command{
return fmt.Errorf("error preparing authz client: %w", err)
}

cmd.Println("Backfilling organizations...")
if err := backfillOrganizations(ctx, db.NewStore(dbConn)); err != nil {
return fmt.Errorf("error while backfilling organizations: %w", err)
}

return nil
},
}
Expand Down
4 changes: 4 additions & 0 deletions database/migrations/000117_organization_entity.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
-- SPDX-License-Identifier: Apache-2.0

-- Postgres doesn't easily drop enum values, down migration is a no-op
4 changes: 4 additions & 0 deletions database/migrations/000117_organization_entity.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
-- SPDX-License-Identifier: Apache-2.0

ALTER TYPE entities ADD VALUE 'organization';
1 change: 1 addition & 0 deletions docs/docs/ref/proto.mdx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions internal/controlplane/handlers_entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,34 @@ func createEntityMessage(

return msg, nil
}

func (s *Server) publishOrganizationEntityEvent(
ctx context.Context,
providerID, projectID uuid.UUID,
login string,
) {
l := zerolog.Ctx(ctx)
msg := message.NewMessage(uuid.New().String(), nil)
msg.SetContext(ctx)

orgProps := properties.NewProperties(map[string]any{
properties.PropertyName: login,
})

event := messages.NewMinderEvent().
WithProjectID(projectID).
WithProviderID(providerID).
WithEntityType(pb.Entity_ENTITY_ORGANIZATION).
WithProperties(orgProps)

if err := event.ToMessage(msg); err != nil {
l.Error().Err(err).Msg("error marshalling organization entity event")
return
}

if err := s.evt.Publish(constants.TopicQueueReconcileEntityAdd, msg); err != nil {
l.Error().Err(err).Msg("error publishing organization entity event")
} else {
l.Info().Str("messageID", msg.UUID).Msg("published organization entity event for execution")
}
}
2 changes: 2 additions & 0 deletions internal/controlplane/handlers_evalstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,8 @@ func dbEntityToEntity(dbEnt db.Entities) minderv1.Entity {
switch dbEnt {
case db.EntitiesPullRequest:
return minderv1.Entity_ENTITY_PULL_REQUESTS
case db.EntitiesOrganization:
return minderv1.Entity_ENTITY_ORGANIZATION
case db.EntitiesArtifact:
return minderv1.Entity_ENTITY_ARTIFACTS
case db.EntitiesRepository:
Expand Down
43 changes: 31 additions & 12 deletions internal/controlplane/handlers_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,18 +436,14 @@ func (s *Server) processAppCallback(ctx context.Context, w http.ResponseWriter,

logger.BusinessRecord(ctx).Project = stateData.ProjectID

var confErr providers.ErrProviderInvalidConfig
_, err = s.ghProviders.CreateGitHubAppProvider(ctx, *token, stateData, installationID, state)
dbProv, err := s.ghProviders.CreateGitHubAppProvider(ctx, *token, stateData, installationID, state)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the only time the return value from CreateGitHubAppProvider is used is in a unit test, it should be easy to change that to return a provider facet or some other struct which can be used to resolve any provider properties we need. (Currently, it seems like mostly the org name, but possibly other data in the future.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than returning a bunch of state which needs to be passed along to publishOrganizationEntityEvent, let's move that call into the ghProviders service, rather than having it here and in handlers_user.go.

if err != nil {
if errors.As(err, &confErr) {
return newHttpError(http.StatusBadRequest, "Invalid provider config").SetContents(
"The provider configuration is invalid: %s", confErr.Details)
}
if errors.Is(err, service.ErrInvalidTokenIdentity) {
return newHttpError(http.StatusForbidden, "User token mismatch").SetContents(
"The provided login token was associated with a different GitHub user.")
}
return fmt.Errorf("error creating GitHub App provider: %w", err)
return handleProviderCreationError(err)
}

if dbProv != nil {
login := strings.TrimPrefix(dbProv.Name, string(db.ProviderClassGithubApp)+"-")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if we don't end up adding a method to fetch the current organization name given the ID, we should at least centralize the name mangling in a function where we can add it later if needed. Ideally, that function would be a part of the GitHub provider.

s.publishOrganizationEntityEvent(ctx, dbProv.ID, dbProv.ProjectID, login)
}

if stateData.RedirectUrl.Valid || stateData.EncryptedRedirect.Valid {
Expand Down Expand Up @@ -541,7 +537,17 @@ func (s *Server) handleAppInstallWithoutInvite(ctx context.Context, token *oauth
}

_, err = db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*db.Project, error) {
return s.ghProviders.CreateGitHubAppWithoutInvitation(ctx, qtx, *userID, installationID)
proj, dbProv, err := s.ghProviders.CreateGitHubAppWithoutInvitation(ctx, qtx, *userID, installationID)
if err != nil {
return nil, err
}
if dbProv != nil && proj != nil {
login := strings.TrimPrefix(dbProv.Name, string(db.ProviderClassGithubApp)+"-")
// It is generally safe to publish an event from within a transaction, as long
// as the event handler evaluates the state matching later.
s.publishOrganizationEntityEvent(ctx, dbProv.ID, proj.ID, login)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that the dbProv object we return from CreateGitHubAppWithoutInvitation and elsewhere could instead include a publishEntityEvent() method, depending on how much trouble it is to plumb s.evt to the ghProvider.

}
return proj, nil
})
return err
}
Expand Down Expand Up @@ -787,3 +793,16 @@ func (s *Server) decryptRedirect(stateData *db.GetProjectIDBySessionStateRow) (*
}
return parsedURL, nil
}

func handleProviderCreationError(err error) error {
var confErr providers.ErrProviderInvalidConfig
if errors.As(err, &confErr) {
return newHttpError(http.StatusBadRequest, "Invalid provider config").SetContents(
"The provider configuration is invalid: %s", confErr.Details)
}
if errors.Is(err, service.ErrInvalidTokenIdentity) {
return newHttpError(http.StatusForbidden, "User token mismatch").SetContents(
"The provided login token was associated with a different GitHub user.")
}
return fmt.Errorf("error creating GitHub App provider: %w", err)
}
2 changes: 1 addition & 1 deletion internal/controlplane/handlers_oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ func TestHandleGitHubAppCallback(t *testing.T) {
db.EXPECT().Rollback(gomock.Any()).Return(nil)
service.EXPECT().
CreateGitHubAppWithoutInvitation(gomock.Any(), gomock.Any(), userId, installationID).
Return(nil, nil)
Return(nil, nil, nil)
},
checkResponse: func(t *testing.T, resp httptest.ResponseRecorder) {
t.Helper()
Expand Down
2 changes: 1 addition & 1 deletion internal/controlplane/handlers_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ func (s *Server) getRuleEvalStatus(
repoPath = fmt.Sprintf("%s/%s", prRepoOwner, prRepoName)
}
case db.EntitiesBuildEnvironment, db.EntitiesRelease, db.EntitiesPipelineRun,
db.EntitiesTaskRun, db.EntitiesBuild:
db.EntitiesTaskRun, db.EntitiesBuild, db.EntitiesOrganization:
Comment on lines 429 to +430

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably okay for now, but it seems like alerts will have incorrect URLs (and log errors) until we fix it. It can be a good idea to file an issue for this if we merge this PR, to track the need to update it later.

zerolog.Ctx(ctx).Warn().Msgf("attempting to set alerts for unsupported entity type: %v", dbRuleEvalStat.EntityType)
default:
zerolog.Ctx(ctx).Error().Msgf("unknown entity type: %v", dbRuleEvalStat.EntityType)
Expand Down
7 changes: 6 additions & 1 deletion internal/controlplane/handlers_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
"path"
"strconv"
"strings"

"github.com/google/uuid"
gauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth"
Expand Down Expand Up @@ -134,11 +135,15 @@ func (s *Server) claimGitHubInstalls(ctx context.Context, qtx db.ExtendQuerier)

for _, i := range installs {
// TODO: if we can get an GitHub auth token for the user, we can do the rest with CreateGitHubAppWithoutInvitation
proj, err := s.ghProviders.CreateGitHubAppWithoutInvitation(ctx, qtx, userID, i.AppInstallationID)
proj, dbProv, err := s.ghProviders.CreateGitHubAppWithoutInvitation(ctx, qtx, userID, i.AppInstallationID)
if err != nil {
zerolog.Ctx(ctx).Error().Err(err).Int64("org_id", i.OrganizationID).Msg("failed to create GitHub app at first login")
continue
}
if dbProv != nil {
login := strings.TrimPrefix(dbProv.Name, string(db.ProviderClassGithubApp)+"-")
s.publishOrganizationEntityEvent(ctx, dbProv.ID, proj.ID, login)
}
Comment on lines +142 to +144

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, let's try to centralize this pattern in the GitHub provider.

if proj != nil {
userProjects = append(userProjects, proj)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/controlplane/handlers_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestCreateUser_gRPC(t *testing.T) {
Return(&db.Project{
ID: projectID,
Name: "github-org1",
}, nil)
}, nil, nil)

store.EXPECT().Commit(gomock.Any())
store.EXPECT().Rollback(gomock.Any())
Expand Down Expand Up @@ -206,14 +206,14 @@ func TestCreateUser_gRPC(t *testing.T) {
Return(&db.Project{
ID: projectID,
Name: "github-org1",
}, nil)
}, nil, nil)

prov.EXPECT().
CreateGitHubAppWithoutInvitation(gomock.Any(), gomock.Any(), int64(31337), int64(11)).
Return(&db.Project{
ID: uuid.New(),
Name: "github-org2",
}, nil)
}, nil, nil)

store.EXPECT().Commit(gomock.Any())
store.EXPECT().Rollback(gomock.Any())
Expand Down
1 change: 1 addition & 0 deletions internal/db/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/eea/eea.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func (e *EEA) buildEntityWrapper(
case db.EntitiesPullRequest:
return e.buildPullRequestInfoWrapper(ctx, entityID, projID)
case db.EntitiesBuildEnvironment, db.EntitiesRelease,
db.EntitiesPipelineRun, db.EntitiesTaskRun, db.EntitiesBuild:
db.EntitiesPipelineRun, db.EntitiesTaskRun, db.EntitiesBuild, db.EntitiesOrganization:
return nil, fmt.Errorf("entity type %q not yet supported", entity)
Comment on lines +258 to 260

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think leaving this this way will prevent us from evaluating policy on organizations (but I'd need to test to be sure). Definitely something to file a follow-up issue on if we leave like this.

default:
return nil, fmt.Errorf("unknown entity type: %q", entity)
Expand Down
4 changes: 4 additions & 0 deletions internal/engine/entities/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ func EntityTypeFromDB(entity db.Entities) minderv1.Entity {
return minderv1.Entity_ENTITY_PIPELINE_RUN
case db.EntitiesTaskRun:
return minderv1.Entity_ENTITY_TASK_RUN
case db.EntitiesOrganization:
return minderv1.Entity_ENTITY_ORGANIZATION
case db.EntitiesBuild:
return minderv1.Entity_ENTITY_BUILD
default:
Expand All @@ -76,6 +78,8 @@ func EntityTypeToDB(entity minderv1.Entity) db.Entities {
dbEnt = db.EntitiesPipelineRun
case minderv1.Entity_ENTITY_TASK_RUN:
dbEnt = db.EntitiesTaskRun
case minderv1.Entity_ENTITY_ORGANIZATION:
dbEnt = db.EntitiesOrganization
case minderv1.Entity_ENTITY_BUILD:
dbEnt = db.EntitiesBuild
case minderv1.Entity_ENTITY_UNSPECIFIED:
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/entities/entity_type_conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func EntityTypeToDBType(entityType pb.Entity) (db.Entities, error) {
return db.EntitiesPipelineRun, nil
case pb.Entity_ENTITY_TASK_RUN:
return db.EntitiesTaskRun, nil
case pb.Entity_ENTITY_ORGANIZATION:
return db.EntitiesOrganization, nil
case pb.Entity_ENTITY_BUILD:
return db.EntitiesBuild, nil
case pb.Entity_ENTITY_UNSPECIFIED:
Expand Down
30 changes: 30 additions & 0 deletions internal/entities/service/validators/organization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package validators

import (
"context"

"github.com/google/uuid"

"github.com/mindersec/minder/pkg/entities/properties"
)

// OrganizationValidator validates organization entity creation
type OrganizationValidator struct{}

// NewOrganizationValidator creates a new OrganizationValidator
func NewOrganizationValidator() *OrganizationValidator {
return &OrganizationValidator{}
}

// Validate checks if an organization entity can be created
func (*OrganizationValidator) Validate(
_ context.Context,
_ *properties.Properties,
_ uuid.UUID,
) error {
// For now, any organization properties that make it this far are valid
return nil
}
Comment thread
Jaydeep869 marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion internal/logger/telemetry_store_watermill.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func newTelemetryStoreFromEntity(inf *entities.EntityInfoWrapper) (*TelemetrySto
ts.PullRequest = ent
case minderv1.Entity_ENTITY_BUILD_ENVIRONMENTS,
minderv1.Entity_ENTITY_RELEASE, minderv1.Entity_ENTITY_PIPELINE_RUN,
minderv1.Entity_ENTITY_TASK_RUN, minderv1.Entity_ENTITY_BUILD:
minderv1.Entity_ENTITY_TASK_RUN, minderv1.Entity_ENTITY_BUILD, minderv1.Entity_ENTITY_ORGANIZATION:
// Noop, see https://github.com/mindersec/minder/issues/3838
case minderv1.Entity_ENTITY_UNSPECIFIED:
// Do nothing
Expand Down
2 changes: 2 additions & 0 deletions internal/providers/github/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func (c *GitHub) RegisterEntity(
case minderv1.Entity_ENTITY_ARTIFACTS:
fallthrough
case minderv1.Entity_ENTITY_RELEASE:
fallthrough
case minderv1.Entity_ENTITY_ORGANIZATION:
// Nothing to do, accept:
return props, nil
case minderv1.Entity_ENTITY_REPOSITORIES:
Expand Down
Loading
Loading