diff --git a/internal/metal/metal.go b/internal/metal/metal.go index f883070..1e567fc 100644 --- a/internal/metal/metal.go +++ b/internal/metal/metal.go @@ -3,10 +3,11 @@ package metal import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" - provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.infratographer.com/x/gidx" "go.uber.org/zap" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" + provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" ) // Client is the Equinix Metal API Client struct. @@ -28,6 +29,14 @@ func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*model return c.provider.GetUserDetails(ctx, id) } +func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return c.provider.GetUserOrganizationRole(ctx, userID, orgID) +} + +func (c *Client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) { + return c.provider.GetUserProjectRole(ctx, userID, projID) +} + // New creates a new Client. func New(options ...Option) (*Client, error) { client := new(Client) diff --git a/internal/metal/providers/emapi/helpers.go b/internal/metal/providers/emapi/helpers.go new file mode 100644 index 0000000..5b80e43 --- /dev/null +++ b/internal/metal/providers/emapi/helpers.go @@ -0,0 +1,27 @@ +package emapi + +import ( + "net/url" + "strings" +) + +// idOrLinkID returns the id if not empty, otherwise it plucks the last subpath from the link. +// An empty string is returned if nothing is defined or an error occurs. +func idOrLinkID(id, link string) string { + if id != "" || link == "" { + return id + } + + url, err := url.Parse(link) + if err != nil { + return "" + } + + parts := strings.Split(strings.TrimRight(url.Path, "/"), "/") + + if len(parts) != 0 { + return parts[len(parts)-1] + } + + return "" +} diff --git a/internal/metal/providers/emapi/organizations.go b/internal/metal/providers/emapi/organizations.go index 3b72944..639e44f 100644 --- a/internal/metal/providers/emapi/organizations.go +++ b/internal/metal/providers/emapi/organizations.go @@ -48,12 +48,18 @@ type Organization struct { } func (o *Organization) ToDetails() *models.OrganizationDetails { - if o == nil || o.ID == "" { + var id string + + if o != nil { + id = idOrLinkID(o.ID, o.HREF) + } + + if id == "" { return nil } details := &models.OrganizationDetails{ - ID: o.ID, + ID: id, Name: o.Name, Projects: o.Projects.ToDetails(), } @@ -66,7 +72,7 @@ func (o *Organization) ToDetails() *models.OrganizationDetails { func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) { var org Organization - _, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user,projects.memberships.user", nil, &org) + _, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user", nil, &org) if err != nil { return nil, fmt.Errorf("error loading organization: %w", err) } diff --git a/internal/metal/providers/emapi/projects.go b/internal/metal/providers/emapi/projects.go index f2c3e7c..71639a8 100644 --- a/internal/metal/providers/emapi/projects.go +++ b/internal/metal/providers/emapi/projects.go @@ -48,12 +48,18 @@ type Project struct { } func (p *Project) ToDetails() *models.ProjectDetails { - if p == nil || p.ID == "" { + var id string + + if p != nil { + id = idOrLinkID(p.ID, p.HREF) + } + + if id == "" { return nil } details := &models.ProjectDetails{ - ID: p.ID, + ID: id, Name: p.Name, Organization: p.Organization.ToDetails(), } @@ -63,19 +69,19 @@ func (p *Project) ToDetails() *models.ProjectDetails { return details } -func (c *Client) getProject(ctx context.Context, id string) (*Project, error) { +func (c *Client) getProjectWithMemberships(ctx context.Context, id string) (*Project, error) { var project Project - _, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project) + _, err := c.DoRequest(ctx, http.MethodGet, projectsPath+"/"+id+"?include=memberships.user", nil, &project) if err != nil { - return nil, fmt.Errorf("error loading project: %w", err) + return nil, fmt.Errorf("error loading organization: %w", err) } return &project, nil } func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { - project, err := c.getProject(ctx, id.String()[gidx.PrefixPartLength+1:]) + project, err := c.getProjectWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:]) if err != nil { return nil, err } diff --git a/internal/metal/providers/emapi/users.go b/internal/metal/providers/emapi/users.go index 354985d..966cbef 100644 --- a/internal/metal/providers/emapi/users.go +++ b/internal/metal/providers/emapi/users.go @@ -47,12 +47,18 @@ type User struct { } func (u *User) ToDetails() *models.UserDetails { - if u.ID == "" { + var id string + + if u != nil { + id = idOrLinkID(u.ID, u.HREF) + } + + if id == "" { return nil } return &models.UserDetails{ - ID: u.ID, + ID: id, FullName: u.FullName, Organizations: nil, Projects: u.Projects.ToDetails(), @@ -62,9 +68,9 @@ func (u *User) ToDetails() *models.UserDetails { func (c *Client) getUser(ctx context.Context, id string) (*User, error) { var user User - _, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(usersPath, id).String(), nil, &user) + _, err := c.DoRequest(ctx, http.MethodGet, usersPath+"/"+id, nil, &user) if err != nil { - return nil, fmt.Errorf("error loading user: %w", err) + return nil, fmt.Errorf("error loading organization: %w", err) } return &user, nil @@ -78,3 +84,11 @@ func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*model return user.ToDetails(), nil } + +func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} + +func (c *Client) GetUserProjectRole(ctx context.Context, userID, projectID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} diff --git a/internal/metal/providers/emgql/users.go b/internal/metal/providers/emgql/users.go index 04e2b4a..52e36c1 100644 --- a/internal/metal/providers/emgql/users.go +++ b/internal/metal/providers/emgql/users.go @@ -3,10 +3,19 @@ package emgql import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { return nil, nil } + +func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} + +func (c *Client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} diff --git a/internal/metal/providers/provider.go b/internal/metal/providers/provider.go index dba8598..0a99521 100644 --- a/internal/metal/providers/provider.go +++ b/internal/metal/providers/provider.go @@ -3,12 +3,15 @@ package provider import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) type Provider interface { GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) + GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) + GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) } diff --git a/internal/permissions/roles.go b/internal/permissions/roles.go index 6bc8e5a..247c5b5 100644 --- a/internal/permissions/roles.go +++ b/internal/permissions/roles.go @@ -51,13 +51,15 @@ func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, act func (c *Client) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) { path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String()) - var response ResourceRoles + var response struct { + Data ResourceRoles `json:"data"` + } if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { return nil, err } - return response, nil + return response.Data, nil } func (c *Client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) { diff --git a/internal/pubsub/subscriber.go b/internal/pubsub/subscriber.go index 75949cb..5d74f37 100644 --- a/internal/pubsub/subscriber.go +++ b/internal/pubsub/subscriber.go @@ -173,7 +173,7 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, } if s.svc.IsUser(changeMsg.SubjectID) { - if err := s.svc.TouchUser(ctx, changeMsg.SubjectID); err != nil { + if err := s.svc.AssignUser(ctx, changeMsg.SubjectID, changeMsg.AdditionalSubjectIDs...); err != nil { // TODO: only return errors on retryable errors return err } @@ -206,7 +206,7 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message } if s.svc.IsUser(changeMsg.SubjectID) { - if err := s.svc.DeleteUser(ctx, changeMsg.SubjectID); err != nil { + if err := s.svc.RemoveUser(ctx, changeMsg.SubjectID, changeMsg.AdditionalSubjectIDs...); err != nil { // TODO: only return errors on retryable errors return err } diff --git a/internal/service/options.go b/internal/service/options.go index 63b093e..0e78a65 100644 --- a/internal/service/options.go +++ b/internal/service/options.go @@ -55,7 +55,7 @@ func WithRootTenant(sid string) Option { return err } - s.rootResource = rootResource{id} + s.rootResource = prefixedID{id} return nil } diff --git a/internal/service/process.go b/internal/service/process.go index f310f51..22492da 100644 --- a/internal/service/process.go +++ b/internal/service/process.go @@ -50,6 +50,7 @@ func (s *service) processMemberships(ctx context.Context, memberships []Resource roleActions[role] = s.roles[role] } + resourceRoleID[resourceID][role] = gidx.NullPrefixedID resourceRoleMembers[resourceID][role][memberID] = true } diff --git a/internal/service/projects.go b/internal/service/projects.go index 14b1f4e..aa81d8c 100644 --- a/internal/service/projects.go +++ b/internal/service/projects.go @@ -4,8 +4,41 @@ import ( "context" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +func (s *service) buildProjectRelationships(project *models.ProjectDetails) (Relationships, error) { + relations := Relationships{ + Relationships: []Relationship{ + // Relate project to organization. + { + Resource: project, + Relation: RelateParent, + RelatedResource: project.Organization, + }, + }, + } + + for _, member := range project.Memberships { + for _, role := range member.Roles { + if _, ok := s.roles[role]; !ok { + s.logger.Warnf("unrecognized project role '%s' for %s on %s", role, member.User.PrefixedID(), project.PrefixedID()) + + continue + } + + relations.Memberships = append(relations.Memberships, ResourceMemberships{ + Resource: project, + Role: role, + Member: member.User, + }) + } + } + + return relations, nil +} + func (s *service) IsProjectID(id gidx.PrefixedID) bool { if idType, ok := s.idPrefixMap[id.Prefix()]; ok { return idType == TypeProject @@ -15,6 +48,27 @@ func (s *service) IsProjectID(id gidx.PrefixedID) bool { } func (s *service) TouchProject(ctx context.Context, id gidx.PrefixedID) error { + logger := s.logger.With("project.id", id.String()) + + project, err := s.metal.GetProjectDetails(ctx, id) + if err != nil { + logger.Errorw("failed to get project", "error", err) + + return err + } + + relationships, err := s.buildProjectRelationships(project) + if err != nil { + logger.Errorw("failed to build project relationships", "error", err) + + return err + } + + s.processRelationships(ctx, "metal-relation", relationships.Relationships) + s.processMemberships(ctx, relationships.Memberships) + + s.logger.Infow("project sync complete", "relationships", len(relationships.Relationships), "memberships", len(relationships.Memberships)) + return nil } diff --git a/internal/service/relationships.go b/internal/service/relationships.go index ebcc4ef..199dc96 100644 --- a/internal/service/relationships.go +++ b/internal/service/relationships.go @@ -2,8 +2,6 @@ package service import ( "go.infratographer.com/x/gidx" - - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) const ( @@ -60,5 +58,5 @@ type Relationship struct { type ResourceMemberships struct { Resource IDPrefixableResource Role string - Member *models.UserDetails + Member IDPrefixableResource } diff --git a/internal/service/service.go b/internal/service/service.go index c344b1b..4b72445 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -47,10 +47,13 @@ type Service interface { // IsUser checks if the provided id has an id prefix which is a user. IsUser(id gidx.PrefixedID) bool - // TouchUser triggers a sync of a user and their permissions. - TouchUser(ctx context.Context, id gidx.PrefixedID) error - // DeleteUser deletes the user and their permissions. - DeleteUser(ctx context.Context, id gidx.PrefixedID) error + // AssignUser assigns a user to the given resource. + AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error + // RemoveUser removes the users from the given resource. + RemoveUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error + + // IsAssignableResource checks if the provided resource ID may have assigned users. + IsAssignableResource(id gidx.PrefixedID) bool } var _ Service = &service{} @@ -62,15 +65,15 @@ type service struct { perms *permissions.Client idPrefixMap map[string]string - rootResource rootResource + rootResource prefixedID roles map[string][]string } -type rootResource struct { +type prefixedID struct { id gidx.PrefixedID } -func (r rootResource) PrefixedID() gidx.PrefixedID { +func (r prefixedID) PrefixedID() gidx.PrefixedID { return r.id } diff --git a/internal/service/users.go b/internal/service/users.go index 0aa8183..961aad4 100644 --- a/internal/service/users.go +++ b/internal/service/users.go @@ -14,10 +14,59 @@ func (s *service) IsUser(id gidx.PrefixedID) bool { return false } -func (s *service) TouchUser(ctx context.Context, id gidx.PrefixedID) error { +func (s *service) IsAssignableResource(id gidx.PrefixedID) bool { + if idType, ok := s.idPrefixMap[id.Prefix()]; ok { + switch idType { + case TypeOrganization, TypeProject: + return true + default: + return false + } + } + + return false +} + +func (s *service) AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error { + var memberships []ResourceMemberships + + for _, resourceID := range resourceIDs { + var ( + role string + err error + ) + + if idType, ok := s.idPrefixMap[resourceID.Prefix()]; ok { + switch idType { + case TypeOrganization: + role, err = s.metal.GetUserOrganizationRole(ctx, userID, resourceID) + case TypeProject: + role, err = s.metal.GetUserProjectRole(ctx, userID, resourceID) + } + } + + if err != nil { + return err + } + + if role == "" { + continue + } + + memberships = append(memberships, ResourceMemberships{ + Resource: prefixedID{resourceID}, + Role: role, + Member: prefixedID{userID}, + }) + } + + s.processMemberships(ctx, memberships) + + s.logger.Infow("assignment sync complete", "memberships", len(memberships)) + return nil } -func (s *service) DeleteUser(ctx context.Context, id gidx.PrefixedID) error { +func (s *service) RemoveUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error { return nil }