Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/docs/ref/proto.mdx

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

110 changes: 110 additions & 0 deletions internal/controlplane/handlers_entity_instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import (

"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/proto"

"github.com/mindersec/minder/internal/engine/engcontext"
"github.com/mindersec/minder/internal/entities/models"
"github.com/mindersec/minder/internal/logger"
"github.com/mindersec/minder/internal/util"
pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
"github.com/mindersec/minder/pkg/entities/properties"
)

// ListEntities returns a list of entity instances for a given project and provider
Expand Down Expand Up @@ -181,3 +184,110 @@ func (s *Server) DeleteEntityById(
Id: in.GetId(),
}, nil
}

// RegisterEntity creates a new entity instance
func (s *Server) RegisterEntity(
ctx context.Context,
in *pb.RegisterEntityRequest,
) (*pb.RegisterEntityResponse, error) {
// 1. Extract context information
entityCtx := engcontext.EntityFromContext(ctx)
projectID := entityCtx.Project.ID
providerName := entityCtx.Provider.Name

logger.BusinessRecord(ctx).Provider = providerName
logger.BusinessRecord(ctx).Project = projectID

// 2. Validate entity type
if in.GetEntityType() == pb.Entity_ENTITY_UNSPECIFIED {
return nil, util.UserVisibleError(codes.InvalidArgument,
"entity_type must be specified")
}

// 3. Parse identifying properties
identifyingProps, err := parseIdentifyingProperties(in)
if err != nil {
return nil, util.UserVisibleError(codes.InvalidArgument,
"invalid identifying_properties: %v", err)
}

// 4. Get provider from database
provider, err := s.providerStore.GetByName(ctx, projectID, providerName)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, util.UserVisibleError(codes.NotFound, "provider not found")
}
return nil, util.UserVisibleError(codes.Internal, "cannot get provider: %v", err)
}

// 5. Create entity using EntityCreator service
ewp, err := s.entityCreator.CreateEntity(ctx, provider, projectID,
in.GetEntityType(), identifyingProps, nil) // Use default options
Copy link
Member

Choose a reason for hiding this comment

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

Given that this path with nil options needs to work, can we remove the need for options from CreateEntity altogether?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Options are needed for originated entities (artifacts, pull requests) that need to pass the parent entity ID via OriginatingEntityID. The nil check provides sensible defaults for the common case (direct RegisterEntity calls), but internal code can override when needed (e.g., AddOriginatingEntityStrategy passes the parent repo ID).

if err != nil {
// If the error is already a UserVisibleError, pass it through directly.
// This allows providers and EntityCreator to add user-visible errors
// without needing to update this allow-list.
var userErr *util.NiceStatus
if errors.As(err, &userErr) {
return nil, err
}
return nil, util.UserVisibleError(codes.Internal,
"unable to register entity: %v", err)
Comment on lines +227 to +235
Copy link
Member

Choose a reason for hiding this comment

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

FYI, I found this pattern elsewhere, and I plan to fix it so that we can write this as:

return nil, fmt.Errorf("unable to register entity: %w", err)

And then the interceptor wrapper will use errors.As to extract a NiceStatus if it's in the wrapping chain.

There's nothing to do here at the moment, though.

Copy link
Member

Choose a reason for hiding this comment

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

On second thought, laundering all errors as user-visible internal errors is probably wrong:

ERROR:
  Code: Internal
  Message: unable to register entity: error registering with provider: error cleaning up stale hooks: error listing hooks: GET https://api.github.com/repos/a-random-sandbox/.github/hooks: 403 Resource not accessible by integration []

This is both gobbledygook from a normal user's point of view, and risks leaking internal details in other cases.

I'm also realizing that I didn't need to specify a provider in my call, which seems surprising?

}

// 6. Convert to EntityInstance protobuf
entityInstance := entityInstanceToProto(ewp, providerName)

// 7. Return response
return &pb.RegisterEntityResponse{
Entity: entityInstance,
}, nil
}

// parseIdentifyingProperties converts proto properties to Properties object
func parseIdentifyingProperties(req *pb.RegisterEntityRequest) (*properties.Properties, error) {
identifyingProps := req.GetIdentifyingProperties()
if len(identifyingProps) == 0 {
return nil, errors.New("identifying_properties is required")
}

// Validate total size to prevent resource exhaustion
// We sum the proto.Size of each value since map itself isn't a proto message
Copy link
Member

Choose a reason for hiding this comment

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

I think this is fine, but there are a couple ways to simplify at the cost of precision:

  1. You could check the size of the RegisterEntityRequest. Since proto doesn't provide a way to e.g. reference one field's content in another (like yaml &reference), this is an upper bound on the map's size.
  2. Additionally, gRPC has a default 4MB MaxRecvMsgSize option, which applies before the deserialization and will prevent truly enormous messages from exhausting the server memory. I agree that 32KB of properties is probably more than sufficient, but the gRPC limit is a nice backup.

const maxProtoSize = 32 * 1024 // 32KB should be plenty for identifying properties
var totalSize int
for _, v := range identifyingProps {
totalSize += proto.Size(v)
}
if totalSize > maxProtoSize {
return nil, fmt.Errorf("identifying_properties too large: %d bytes, max %d bytes",
totalSize, maxProtoSize)
}

// Convert map[string]*structpb.Value to map[string]any
propsMap := make(map[string]any, len(identifyingProps))
for key, value := range identifyingProps {
// Validate property keys are reasonable length
if len(key) > 200 {
return nil, fmt.Errorf("property key too long: %d characters", len(key))
}
if value != nil {
propsMap[key] = value.AsInterface()
}
}

return properties.NewProperties(propsMap), nil
}

// entityInstanceToProto converts EntityWithProperties to EntityInstance protobuf
func entityInstanceToProto(ewp *models.EntityWithProperties, providerName string) *pb.EntityInstance {
return &pb.EntityInstance{
Id: ewp.Entity.ID.String(),
Context: &pb.ContextV2{
ProjectId: ewp.Entity.ProjectID.String(),
Provider: providerName,
},
Type: ewp.Entity.Type,
Name: ewp.Entity.Name,
// Properties are intentionally omitted - use GetEntityById to fetch them
}
}
Loading
Loading