initial commit

This commit is contained in:
Mike Mason
2023-07-01 00:04:52 +00:00
commit 80fb879ef6
65 changed files with 3544 additions and 0 deletions

31
internal/config/config.go Normal file
View File

@@ -0,0 +1,31 @@
// Package config defines app configuration.
package config
import (
"go.infratographer.com/x/echox"
"go.infratographer.com/x/events"
"go.infratographer.com/x/loggingx"
"go.infratographer.com/x/otelx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
)
// AppConfig is the main application configuration.
var AppConfig struct {
Logging loggingx.Config
EquinixMetal metal.Config
OTel otelx.Config
Server echox.Config
Service service.Config
Events EventsConfig
Roles service.ConfigRoles
Permissions permissions.Config
}
// EventsConfig defines the configuration setting up both subscriptions and publishing
type EventsConfig struct {
Publisher events.PublisherConfig
Subscriber events.SubscriberConfig
}

15
internal/metal/config.go Normal file
View File

@@ -0,0 +1,15 @@
package metal
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
)
// Config provides configuration for connecting to the metal provider.
type Config struct {
// EMGQL sets the provider to Equinix Metal GraphQL.
EMGQL emgql.Config
// EMAPI sets the provider to Equinix Metal API.
EMAPI emapi.Config
}

2
internal/metal/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package metal provides a simple interface for getting details about organizations, projects and users.
package metal

8
internal/metal/errors.go Normal file
View File

@@ -0,0 +1,8 @@
package metal
import "errors"
var (
// ErrUnauthorized is returned when the token provided did not validate to a user.
ErrUnauthorized = errors.New("unauthorized key")
)

46
internal/metal/metal.go Normal file
View File

@@ -0,0 +1,46 @@
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"
)
// Client is the Equinix Metal API Client struct.
type Client struct {
logger *zap.Logger
provider provider.Provider
}
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return c.provider.GetOrganizationDetails(ctx, id)
}
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return c.provider.GetProjectDetails(ctx, id)
}
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return c.provider.GetUserDetails(ctx, id)
}
// New creates a new Client.
func New(options ...Option) (*Client, error) {
client := new(Client)
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop()
}
return client, nil
}

View File

@@ -0,0 +1,12 @@
package models
const (
// IDPrefixOrganization defines the ID Prefix for an Organization.
IDPrefixOrganization = "metlorg"
// IDPrefixProject defines the ID Prefix for a Project.
IDPrefixProject = "metlprj"
// IDPrefixUser defines the ID Prefix for a User.
IDPrefixUser = "metlusr"
)

View File

@@ -0,0 +1,8 @@
package models
type Membership[T any] struct {
ID string `json:"id"`
User *UserDetails `json:"user"`
Entity *T `json:"entity"`
Roles []string `json:"roles"`
}

View File

@@ -0,0 +1,18 @@
package models
import "go.infratographer.com/x/gidx"
type OrganizationDetails struct {
ID string `json:"id"`
Name string `json:"name"`
Memberships []*Membership[OrganizationDetails] `json:"memberships"`
Projects []*ProjectDetails `json:"projects"`
}
func (d *OrganizationDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixOrganization + "-" + d.ID)
}

View File

@@ -0,0 +1,18 @@
package models
import "go.infratographer.com/x/gidx"
type ProjectDetails struct {
ID string `json:"id"`
Name string `json:"name"`
Memberships []*Membership[ProjectDetails] `json:"memberships"`
Organization *OrganizationDetails `json:"organization"`
}
func (d *ProjectDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixProject + "-" + d.ID)
}

View File

@@ -0,0 +1,23 @@
package models
import "go.infratographer.com/x/gidx"
const (
MetalUserPrefix = "metlusr"
)
type UserDetails struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Organizations []*OrganizationDetails `json:"organizations"`
Projects []*ProjectDetails `json:"projects"`
Roles []string `json:"roles"`
}
func (d *UserDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" {
return gidx.NullPrefixedID
}
return gidx.PrefixedID(IDPrefixUser + "-" + d.ID)
}

60
internal/metal/options.go Normal file
View File

@@ -0,0 +1,60 @@
package metal
import (
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithProvider sets the provider on the client.
func WithProvider(provider provider.Provider) Option {
return func(c *Client) error {
c.provider = provider
return nil
}
}
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.Logger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.EMGQL.Populated() {
client, err := emgql.New(emgql.WithConfig(config.EMGQL))
if err != nil {
return err
}
options = append(options, WithProvider(client))
} else if config.EMAPI.Populated() {
client, err := emapi.New(emapi.WithConfig(config.EMAPI))
if err != nil {
return err
}
options = append(options, WithProvider(client))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,134 @@
package emapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
)
const (
defaultHTTPTimeout = 20 * time.Second
defaultBaseURL = "https://api.equinix.com/metal/v1"
authHeader = "X-Auth-Token"
consumerHeader = "X-Consumer-Token"
staffHeader = "X-Packet-Staff"
staffHeaderValue = "true"
)
var DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout,
}
var _ provider.Provider = &Client{}
type Client struct {
logger *zap.SugaredLogger
httpClient *http.Client
baseURL *url.URL
authToken string
consumerToken string
}
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
if c.authToken != "" {
req.Header.Set(authHeader, c.authToken)
}
if c.consumerToken != "" {
req.Header.Set(consumerHeader, c.consumerToken)
}
if c.authToken != "" && c.consumerToken != "" {
req.Header.Set(staffHeader, staffHeaderValue)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if out != nil {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp, err
}
}
return resp, nil
}
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
path = strings.TrimPrefix(path, c.baseURL.Path)
pathURL, err := url.Parse(path)
if err != nil {
return nil, err
}
url := c.baseURL.JoinPath(pathURL.Path)
query := url.Query()
for k, v := range pathURL.Query() {
query[k] = v
}
url.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
resp, err := c.Do(req, out)
if err != nil {
return nil, fmt.Errorf("failed to get response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp, nil
}
// New creates a new emapi client
func New(options ...Option) (*Client, error) {
client := &Client{}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop().Sugar()
}
if client.httpClient == nil {
client.httpClient = DefaultHTTPClient
}
if client.baseURL == nil {
u, err := url.Parse(defaultBaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse emapi base url %s: %w", defaultBaseURL, err)
}
client.baseURL = u
}
return client, nil
}

View File

@@ -0,0 +1,81 @@
package emapi
import (
"fmt"
"net/url"
)
// Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string
// AuthToken is the token to interact with the Equinix Metal API
AuthToken string
// ConsumerToken is the token to grant higher privileges in the Equinix Metal API
ConsumerToken string
}
func (c Config) Populated() bool {
return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != ""
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.BaseURL != "" {
options = append(options, WithBaseURL(config.BaseURL))
}
if config.AuthToken != "" {
options = append(options, WithAuthToken(config.AuthToken))
}
if config.ConsumerToken != "" {
options = append(options, WithConsumerToken(config.ConsumerToken))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}
// WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
u, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err)
}
c.baseURL = u
return nil
}
}
// WithAuthToken sets the auth token to authenticate the request with.
func WithAuthToken(token string) Option {
return func(c *Client) error {
c.authToken = token
return nil
}
}
// WithConsumerToken sets the consumer token to elevate privileges for the request.
func WithConsumerToken(token string) Option {
return func(c *Client) error {
c.consumerToken = token
return nil
}
}

View File

@@ -0,0 +1,5 @@
package emapi
import "errors"
var ErrBaseURLRequired = errors.New("emapi base url required")

View File

@@ -0,0 +1,84 @@
package emapi
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
type Roles []string
type Memberships []*Membership
func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] {
memberships := make([]*models.Membership[models.OrganizationDetails], len(m))
var nextIndex int
for _, membership := range m {
memberships[nextIndex] = membership.ToDetailsWithOrganizationDetails(orgDetails)
if memberships[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(m) {
return memberships[:nextIndex]
}
return memberships
}
func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] {
memberships := make([]*models.Membership[models.ProjectDetails], len(m))
var nextIndex int
for _, membership := range m {
memberships[nextIndex] = membership.ToDetailsWithProjectDetails(projDetails)
if memberships[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(m) {
return memberships[:nextIndex]
}
return memberships
}
type Membership struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Roles Roles `json:"roles"`
User *User `json:"user"`
}
func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] {
if m.ID == "" {
return nil
}
return &models.Membership[models.OrganizationDetails]{
ID: m.ID,
Roles: m.Roles,
User: m.User.ToDetails(),
Entity: orgDetails,
}
}
func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] {
if m.ID == "" {
return nil
}
return &models.Membership[models.ProjectDetails]{
ID: m.ID,
Roles: m.Roles,
User: m.User.ToDetails(),
Entity: projDetails,
}
}

View File

@@ -0,0 +1,17 @@
package emapi
import (
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}

View File

@@ -0,0 +1,84 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
organizationsPath = "/organizations"
)
type Organizations []*Organization
func (o Organizations) ToDetails() []*models.OrganizationDetails {
orgs := make([]*models.OrganizationDetails, len(o))
var nextIndex int
for _, org := range o {
orgs[nextIndex] = org.ToDetails()
if orgs[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(o) {
return orgs[:nextIndex]
}
return orgs
}
type Organization struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Memberships Memberships `json:"memberships"`
Projects Projects `json:"projects"`
}
func (o *Organization) ToDetails() *models.OrganizationDetails {
if o == nil || o.ID == "" {
return nil
}
details := &models.OrganizationDetails{
ID: o.ID,
Name: o.Name,
Projects: o.Projects.ToDetails(),
}
details.Memberships = o.Memberships.ToDetailsWithOrganizationDetails(details)
return details
}
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)
if err != nil {
return nil, fmt.Errorf("error loading organization: %w", err)
}
return &org, nil
}
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil {
return nil, err
}
return org.ToDetails(), nil
}

View File

@@ -0,0 +1,84 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
projectsPath = "/projects"
)
type Projects []*Project
func (p Projects) ToDetails() []*models.ProjectDetails {
projects := make([]*models.ProjectDetails, len(p))
var nextIndex int
for _, project := range p {
projects[nextIndex] = project.ToDetails()
if projects[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(p) {
return projects[:nextIndex]
}
return projects
}
type Project struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Memberships Memberships `json:"memberships"`
Organization *Organization `json:"organization"`
}
func (p *Project) ToDetails() *models.ProjectDetails {
if p == nil || p.ID == "" {
return nil
}
details := &models.ProjectDetails{
ID: p.ID,
Name: p.Name,
Organization: p.Organization.ToDetails(),
}
details.Memberships = p.Memberships.ToDetailsWithProjectDetails(details)
return details
}
func (c *Client) getProject(ctx context.Context, id string) (*Project, error) {
var project Project
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project)
if err != nil {
return nil, fmt.Errorf("error loading project: %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:])
if err != nil {
return nil, err
}
return project.ToDetails(), nil
}

View File

@@ -0,0 +1,80 @@
package emapi
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
usersPath = "/users"
)
type Users []*User
func (u Users) ToDetails() []*models.UserDetails {
users := make([]*models.UserDetails, len(u))
var nextIndex int
for _, user := range u {
users[nextIndex] = user.ToDetails()
if users[nextIndex] != nil {
nextIndex++
}
}
if nextIndex < len(u) {
return users[:nextIndex]
}
return users
}
type User struct {
client *Client
HREF string `json:"href"`
ID string `json:"id"`
FullName string `json:"full_name"`
Email string `json:"email"`
Projects Projects `json:"projects"`
}
func (u *User) ToDetails() *models.UserDetails {
if u.ID == "" {
return nil
}
return &models.UserDetails{
ID: u.ID,
FullName: u.FullName,
Organizations: nil,
Projects: u.Projects.ToDetails(),
}
}
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)
if err != nil {
return nil, fmt.Errorf("error loading user: %w", err)
}
return &user, nil
}
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil {
return nil, err
}
return user.ToDetails(), nil
}

View File

@@ -0,0 +1,49 @@
package emgql
import (
"net/http"
"net/url"
"time"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.uber.org/zap"
)
const (
defaultHTTPTimeout = 5 * time.Second
)
var (
DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout,
}
)
var _ provider.Provider = &Client{}
type Client struct {
logger *zap.SugaredLogger
httpClient *http.Client
baseURL *url.URL
}
// New creates a new emapi client
func New(options ...Option) (*Client, error) {
client := &Client{}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.logger == nil {
client.logger = zap.NewNop().Sugar()
}
if client.httpClient == nil {
client.httpClient = DefaultHTTPClient
}
return client, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
// Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string
}
func (c Config) Populated() bool {
return c.BaseURL != ""
}

View File

@@ -0,0 +1,52 @@
package emgql
import (
"net/url"
"go.uber.org/zap"
)
// Option is a Client configuration Option definition.
type Option func(c *Client) error
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}
// WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
u, err := url.Parse(baseURL)
if err != nil {
return err
}
c.baseURL = u
return nil
}
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.BaseURL != "" {
options = append(options, WithBaseURL(config.BaseURL))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,12 @@
package emgql
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return nil, nil
}

View File

@@ -0,0 +1,14 @@
package provider
import (
"context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx"
)
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)
}

View File

@@ -0,0 +1,84 @@
package permissions
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
)
type RoleAssign struct {
SubjectID string `json:"subject_id"`
}
type RoleAssignResponse struct {
Success bool `json:"success"`
}
type roleAssignmentData struct {
Data []struct {
SubjectID string `json:"subject_id"`
} `json:"data"`
}
func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error {
path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String())
body, err := encodeJSON(RoleAssign{
SubjectID: memberID.String(),
})
if err != nil {
return err
}
var response RoleAssignResponse
if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil {
return err
}
if !response.Success {
return ErrAssignmentFailed
}
return nil
}
func (c *Client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) {
path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String())
var response roleAssignmentData
if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
assignments := make([]gidx.PrefixedID, len(response.Data))
for i, assignment := range response.Data {
id, err := gidx.Parse(assignment.SubjectID)
if err != nil {
return nil, fmt.Errorf("%w: failed parsing id %s", err, assignment.SubjectID)
}
assignments[i] = id
}
return assignments, nil
}
func (c *Client) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) {
assignments, err := c.ListRoleAssignments(ctx, roleID)
if err != nil {
return false, err
}
for _, assignment := range assignments {
if assignment == memberID {
return true, nil
}
}
return false, nil
}

View File

@@ -0,0 +1,128 @@
package permissions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
const defaultPermissionsURL = "https://permissions-api.hollow-a.sv15.metalkube.net"
var defaultHTTPClient = &http.Client{
Timeout: 5 * time.Second,
}
type Client struct {
logger *zap.SugaredLogger
httpClient *http.Client
token string
baseURL *url.URL
allowURL *url.URL
}
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
if c.token != "" {
req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if out != nil {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp, err
}
}
return resp, nil
}
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
path = strings.TrimPrefix(path, c.baseURL.Path)
pathURL, err := url.Parse(path)
if err != nil {
return nil, err
}
url := c.baseURL.JoinPath(pathURL.Path)
query := url.Query()
for k, v := range pathURL.Query() {
query[k] = v
}
url.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
if err != nil {
return nil, fmt.Errorf("failed to build request: %w", err)
}
resp, err := c.Do(req, out)
if err != nil {
return nil, fmt.Errorf("failed to get response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp, nil
}
func encodeJSON(v any) (*bytes.Buffer, error) {
var buff bytes.Buffer
if err := json.NewEncoder(&buff).Encode(v); err != nil {
return nil, err
}
return &buff, nil
}
func NewClient(token string, options ...Option) (*Client, error) {
client := &Client{
logger: zap.NewNop().Sugar(),
httpClient: defaultHTTPClient,
token: token,
}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if client.baseURL == nil {
uri, err := url.Parse(defaultPermissionsURL)
if err != nil {
return nil, err
}
client.baseURL = uri
}
client.allowURL = client.baseURL.JoinPath("/api/v1/allow")
return client, nil
}

View File

@@ -0,0 +1,73 @@
package permissions
import (
"fmt"
"net/url"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.infratographer.com/x/viperx"
)
// Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string
// BearerToken is the token to interact with the Equinix Metal API
BearerToken string
}
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.String("permissions-baseurl", "", "permissions base url")
viperx.MustBindFlag(v, "permissions.baseurl", flags.Lookup("permissions-baseurl"))
flags.String("permissions-token", "", "permissions bearer url")
viperx.MustBindFlag(v, "permissions.bearertoken", flags.Lookup("permissions-token"))
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(c *Client) error {
var options []Option
if config.BaseURL != "" {
options = append(options, WithBaseURL(config.BaseURL))
}
if config.BearerToken != "" {
options = append(options, WithBearerToken(config.BearerToken))
}
for _, opt := range options {
if err := opt(c); err != nil {
return err
}
}
return nil
}
}
// WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
u, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err)
}
c.baseURL = u
return nil
}
}
// WithBearerToken sets the bearer token to authenticate the request with.
func WithBearerToken(token string) Option {
return func(c *Client) error {
c.token = token
return nil
}
}

View File

@@ -0,0 +1,8 @@
package permissions
import "errors"
var (
ErrRoleNotFound = errors.New("role not found")
ErrAssignmentFailed = errors.New("assignment failed")
)

View File

@@ -0,0 +1,15 @@
package permissions
import (
"go.uber.org/zap"
)
type Option func(*Client) error
func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error {
c.logger = logger
return nil
}
}

View File

@@ -0,0 +1,82 @@
package permissions
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
"golang.org/x/exp/slices"
)
type ResourceRoleCreate struct {
Actions []string `json:"actions"`
}
type ResourceRoleCreateResponse struct {
ID string `json:"id"`
}
type ResourceRoles []ResourceRole
type ResourceRole struct {
ID gidx.PrefixedID `json:"id"`
Actions []string `json:"actions"`
}
func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) {
path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String())
body, err := encodeJSON(ResourceRoleCreate{
Actions: actions,
})
if err != nil {
return gidx.NullPrefixedID, err
}
var response ResourceRoleCreateResponse
if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil {
return gidx.NullPrefixedID, err
}
roleID, err := gidx.Parse(response.ID)
if err != nil {
return gidx.NullPrefixedID, err
}
return roleID, nil
}
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
if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil {
return nil, err
}
return response, nil
}
func (c *Client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) {
roles, err := c.ListResourceRoles(ctx, resourceID)
if err != nil {
return ResourceRole{}, err
}
slices.Sort(actions)
for _, role := range roles {
roleActions := role.Actions
slices.Sort(roleActions)
if slices.Equal(actions, roleActions) {
return role, nil
}
}
return ResourceRole{}, ErrRoleNotFound
}

2
internal/pubsub/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package pubsub provides functions and data for a NATS consumer listening for resource lifecycle events.
package pubsub

View File

@@ -0,0 +1,220 @@
package pubsub
import (
"context"
"sync"
nc "github.com/nats-io/nats.go"
"go.infratographer.com/x/events"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
"github.com/ThreeDotsLabs/watermill/message"
)
var tracer = otel.Tracer("go.infratographer.com/permissions-api/internal/pubsub")
// Subscriber is the subscriber client
type Subscriber struct {
ctx context.Context
changeChannels []<-chan *message.Message
logger *zap.SugaredLogger
subscriber *events.Subscriber
subOpts []nc.SubOpt
svc service.Service
}
// SubscriberOption is a functional option for the Subscriber
type SubscriberOption func(s *Subscriber)
// WithLogger sets the logger for the Subscriber
func WithLogger(l *zap.SugaredLogger) SubscriberOption {
return func(s *Subscriber) {
s.logger = l
}
}
// WithNatsSubOpts sets the logger for the Subscriber
func WithNatsSubOpts(options ...nc.SubOpt) SubscriberOption {
return func(s *Subscriber) {
s.subOpts = append(s.subOpts, options...)
}
}
// NewSubscriber creates a new Subscriber
func NewSubscriber(ctx context.Context, cfg events.SubscriberConfig, service service.Service, opts ...SubscriberOption) (*Subscriber, error) {
s := &Subscriber{
ctx: ctx,
logger: zap.NewNop().Sugar(),
svc: service,
}
for _, opt := range opts {
opt(s)
}
sub, err := events.NewSubscriber(cfg, s.subOpts...)
if err != nil {
return nil, err
}
s.subscriber = sub
s.logger.Debugw("subscriber configuration", "config", cfg)
return s, nil
}
// Subscribe subscribes to a nats subject
func (s *Subscriber) Subscribe(topic string) error {
msgChan, err := s.subscriber.SubscribeChanges(s.ctx, topic)
if err != nil {
return err
}
s.changeChannels = append(s.changeChannels, msgChan)
s.logger.Infof("Subscribing to topic %s", topic)
return nil
}
// Listen start listening for messages on registered subjects and calls the registered message handler
func (s Subscriber) Listen() error {
wg := &sync.WaitGroup{}
// goroutine for each change channel
for _, ch := range s.changeChannels {
wg.Add(1)
go s.listen(ch, wg)
}
wg.Wait()
return nil
}
// listen listens for messages on a channel and calls the registered message handler
func (s Subscriber) listen(messages <-chan *message.Message, wg *sync.WaitGroup) {
defer wg.Done()
for msg := range messages {
s.logger.Infow("processing event", "event.id", msg.UUID)
if err := s.processEvent(msg); err != nil {
s.logger.Warn("Failed to process msg: ", err)
s.logger.Infow("message nacked", "event.id", msg.UUID)
msg.Nack()
} else {
s.logger.Infow("message acked", "event.id", msg.UUID)
msg.Ack()
}
}
}
// Close closes the subscriber connection and unsubscribes from all subscriptions
func (s *Subscriber) Close() error {
return s.subscriber.Close()
}
// processEvent event message handler
func (s *Subscriber) processEvent(msg *message.Message) error {
changeMsg, err := events.UnmarshalChangeMessage(msg.Payload)
if err != nil {
s.logger.Errorw("failed to process data in msg", zap.Error(err))
return err
}
ctx, span := tracer.Start(context.Background(), "pubsub.receive", trace.WithAttributes(attribute.String("pubsub.subject", changeMsg.SubjectID.String())))
defer span.End()
switch events.ChangeType(changeMsg.EventType) {
case events.CreateChangeType:
err = s.handleTouchEvent(ctx, msg, changeMsg)
case events.UpdateChangeType:
err = s.handleTouchEvent(ctx, msg, changeMsg)
case events.DeleteChangeType:
err = s.handleDeleteEvent(ctx, msg, changeMsg)
default:
s.logger.Warnw("ignoring msg, not a create, update or delete event", "event_type", changeMsg.EventType)
}
if err != nil {
return err
}
return nil
}
func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error {
if s.svc.IsOrganizationID(changeMsg.SubjectID) {
if err := s.svc.TouchOrganization(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
if s.svc.IsProjectID(changeMsg.SubjectID) {
if err := s.svc.TouchProject(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
if s.svc.IsUser(changeMsg.SubjectID) {
if err := s.svc.TouchUser(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID)
return nil
}
func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error {
if s.svc.IsOrganizationID(changeMsg.SubjectID) {
if err := s.svc.DeleteOrganization(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
if s.svc.IsProjectID(changeMsg.SubjectID) {
if err := s.svc.DeleteProject(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
if s.svc.IsUser(changeMsg.SubjectID) {
if err := s.svc.DeleteUser(ctx, changeMsg.SubjectID); err != nil {
// TODO: only return errors on retryable errors
return err
}
return nil
}
s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID)
return nil
}

22
internal/routes/errors.go Normal file
View File

@@ -0,0 +1,22 @@
package routes
import (
"errors"
"net/http"
"github.com/labstack/echo/v4"
)
var (
// ErrInvalidJWTPrivateKeyType is returned when the private key type is not of an expected value.
ErrInvalidJWTPrivateKeyType = errors.New("invalid JWT private key provided")
// ErrAuthTokenHeaderRequired is returned when a token check request is made, but the Authorization header is missing.
ErrAuthTokenHeaderRequired = echo.NewHTTPError(http.StatusBadRequest, "header Authorization missing or invalid")
// ErrInvalidSigningMethod is returned when defined jwt signing method is not recognized.
ErrInvalidSigningMethod = errors.New("unrecognized jwt signing method provided")
// ErrMissingIssuer is returned when the jwt issuer is not defined in the config.
ErrMissingIssuer = errors.New("jwt issuer required")
)

View File

@@ -0,0 +1,27 @@
package routes
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
"go.uber.org/zap"
)
// Option is a functional configuration option for the router.
type Option func(r *Router) error
// WithLogger sets the logger for the router.
func WithLogger(logger *zap.Logger) Option {
return func(r *Router) error {
r.logger = logger
return nil
}
}
// WithService sets the service handler.
func WithService(svc service.Service) Option {
return func(r *Router) error {
r.svc = svc
return nil
}
}

32
internal/routes/routes.go Normal file
View File

@@ -0,0 +1,32 @@
// Package routes provides the routes for the application.
package routes
import (
"github.com/labstack/echo/v4"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
"go.uber.org/zap"
)
// Router is the router for the application.
type Router struct {
logger *zap.Logger
svc service.Service
}
// Routes registers the routes for the application.
func (r *Router) Routes(g *echo.Group) {}
// NewRouter creates a new router
func NewRouter(opts ...Option) (*Router, error) {
router := Router{
logger: zap.NewNop(),
}
for _, opt := range opts {
if err := opt(&router); err != nil {
return nil, err
}
}
return &router, nil
}

View File

@@ -0,0 +1,9 @@
package service
// Config defines the service configuration.
type Config struct {
RootTenant string `mapstructure:"root_tenant"`
}
// ConfigRoles is a list of roles to a list of actions
type ConfigRoles map[string][]string

2
internal/service/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package service handles integrating Equinix Metal with Infratographer
package service

View File

@@ -0,0 +1,14 @@
package service
import "errors"
var (
// ErrRootTenantRequired is returned when no root tenant has been defined for the service.
ErrRootTenantRequired = errors.New("root tenant required")
// ErrPublisherRequired is defined when a publisher is not provided to the service.
ErrPublisherRequired = errors.New("publisher required")
// ErrRoleUnrecognized is returned when no corresponding role was matched.
ErrRoleUnrecognized = errors.New("unrecognized role")
)

View File

@@ -0,0 +1,90 @@
package service
import (
"go.infratographer.com/x/gidx"
"go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
)
// Option defines a service option.
type Option func(s *service) error
// WithLogger sets the logger for the service handler.
func WithLogger(logger *zap.SugaredLogger) Option {
return func(s *service) error {
s.logger = logger
return nil
}
}
// WithMetalClient sets the Equinix Metal client used by the service.
func WithMetalClient(client *metal.Client) Option {
return func(s *service) error {
s.metal = client
return nil
}
}
// WithPermissionsClient sets the permissions client used by the service.
func WithPermissionsClient(client *permissions.Client) Option {
return func(s *service) error {
s.perms = client
return nil
}
}
// WithPrefixMap sets the id prefix map relating id prefixes to type names.
func WithPrefixMap(idMap map[string]string) Option {
return func(s *service) error {
s.idPrefixMap = idMap
return nil
}
}
// WithRootTenant sets the root tenant referenced in organization relationships.
func WithRootTenant(sid string) Option {
return func(s *service) error {
id, err := gidx.Parse(sid)
if err != nil {
return err
}
s.rootResource = rootResource{id}
return nil
}
}
// WithRoles defines the role to action mapping.
func WithRoles(roles map[string][]string) Option {
return func(s *service) error {
s.roles = roles
return nil
}
}
// WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option {
return func(s *service) error {
var options []Option
if config.RootTenant != "" {
options = append(options, WithRootTenant(config.RootTenant))
}
for _, opt := range options {
if err := opt(s); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,101 @@
package service
import (
"context"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails) (Relationships, error) {
relations := Relationships{
Relationships: []Relationship{
// Related org to the root tenant.
{
Resource: org,
Relation: RelateParent,
RelatedResource: s.rootResource,
},
},
}
for _, member := range org.Memberships {
for _, role := range member.Roles {
if _, ok := s.roles[role]; !ok {
s.logger.Warnf("unrecognized organization role '%s' for %s on %s", role, member.User.PrefixedID(), org.PrefixedID())
continue
}
relations.Memberships = append(relations.Memberships, ResourceMemberships{
Resource: org,
Role: role,
Member: member.User,
})
}
}
for _, project := range org.Projects {
relations.Relationships = append(relations.Relationships, Relationship{
Resource: project,
Relation: RelateParent,
RelatedResource: org,
})
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) IsOrganizationID(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeOrganization
}
return false
}
func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) error {
logger := s.logger.With("organization.id", id.String())
org, err := s.metal.GetOrganizationDetails(ctx, id)
if err != nil {
logger.Errorw("failed to get organization", "error", err)
return err
}
relationships, err := s.buildOrganizationRelationships(org)
if err != nil {
logger.Errorw("failed to build organization relationships", "error", err)
return err
}
s.processRelationships(ctx, "metal-relation", relationships.Relationships)
s.processMemberships(ctx, relationships.Memberships)
s.logger.Infow("organization sync complete", "relationships", len(relationships.Relationships), "memberships", len(relationships.Memberships))
return nil
}
func (s *service) DeleteOrganization(ctx context.Context, id gidx.PrefixedID) error {
return nil
}

137
internal/service/process.go Normal file
View File

@@ -0,0 +1,137 @@
package service
import (
"context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
)
func (s *service) processRelationships(ctx context.Context, subjectType string, relationships []Relationship) {
var err error
for _, rel := range relationships {
err = s.publisher.PublishChange(ctx, subjectType, events.ChangeMessage{
SubjectID: rel.Resource.PrefixedID(),
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
rel.RelatedResource.PrefixedID(),
},
})
if err != nil {
s.logger.Errorw("error publishing change",
"subject_type", subjectType,
"resource.id", rel.Resource.PrefixedID(),
"related_resource.id", rel.RelatedResource.PrefixedID(),
"error", err,
)
}
}
}
func (s *service) processMemberships(ctx context.Context, memberships []ResourceMemberships) {
resourceRoleID := make(map[gidx.PrefixedID]map[string]gidx.PrefixedID)
resourceRoleMembers := make(map[gidx.PrefixedID]map[string]map[gidx.PrefixedID]bool)
roleActions := make(map[string][]string)
for _, membership := range memberships {
resourceID := membership.Resource.PrefixedID()
role := membership.Role
memberID := membership.Member.PrefixedID()
if _, ok := resourceRoleMembers[resourceID]; !ok {
resourceRoleID[resourceID] = make(map[string]gidx.PrefixedID)
resourceRoleMembers[resourceID] = make(map[string]map[gidx.PrefixedID]bool)
}
if _, ok := resourceRoleMembers[resourceID][role]; !ok {
resourceRoleMembers[resourceID][role] = make(map[gidx.PrefixedID]bool)
roleActions[role] = s.roles[role]
}
resourceRoleMembers[resourceID][role][memberID] = true
}
resourceRoleAssignments := make(map[gidx.PrefixedID]map[gidx.PrefixedID]map[gidx.PrefixedID]bool)
for resourceID, roles := range resourceRoleID {
resourceRoleAssignments[resourceID] = make(map[gidx.PrefixedID]map[gidx.PrefixedID]bool)
for role := range roles {
actions := roleActions[role]
resourceRole, err := s.perms.FindResourceRoleByActions(ctx, resourceID, actions)
if err != nil {
s.logger.Warnw("failed to find role by actions for resource", "resource.id", resourceID, "role", role, "actions", actions, "error", err)
continue
}
resourceRoleID[resourceID][role] = resourceRole.ID
resourceRoleAssignments[resourceID][resourceRole.ID] = make(map[gidx.PrefixedID]bool)
assignments, err := s.perms.ListRoleAssignments(ctx, resourceRole.ID)
if err != nil {
s.logger.Warnw("failed to get role assignments for resource", "resource.id", resourceID, "role", role, "error", err)
continue
}
for _, assignment := range assignments {
resourceRoleAssignments[resourceID][resourceRole.ID][assignment] = true
}
}
}
for resourceID, roles := range resourceRoleMembers {
for role, members := range roles {
roleID := resourceRoleID[resourceID][role]
actions := roleActions[role]
logger := s.logger.With("resource.id", resourceID, "role.name", role, "actions", actions)
var createdRole bool
if roleID == gidx.NullPrefixedID {
logger.Infow("creating role for resource")
resourceRoleID, err := s.perms.CreateRole(ctx, resourceID, actions)
if err != nil {
logger.Errorw("failed to create role for resource", "error", err)
continue
}
createdRole = true
roleID = resourceRoleID
}
logger = logger.With("role.id", roleID)
assignments := make(map[gidx.PrefixedID]bool)
if !createdRole {
assignments = resourceRoleAssignments[resourceID][roleID]
}
for memberID := range members {
mlogger := logger.With("member.id", memberID)
if _, ok := assignments[memberID]; ok {
mlogger.Infow("skipping already assigned member")
continue
}
if err := s.perms.AssignRole(ctx, roleID, memberID); err != nil {
mlogger.Errorw("failed to assign member to role", "error", err)
continue
}
mlogger.Infow("role assigned to member")
}
}
}
}

View File

@@ -0,0 +1,23 @@
package service
import (
"context"
"go.infratographer.com/x/gidx"
)
func (s *service) IsProjectID(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeProject
}
return false
}
func (s *service) TouchProject(ctx context.Context, id gidx.PrefixedID) error {
return nil
}
func (s *service) DeleteProject(ctx context.Context, id gidx.PrefixedID) error {
return nil
}

View File

@@ -0,0 +1,64 @@
package service
import (
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
const (
RelateOwner RelationshipType = "owner"
RelateParent RelationshipType = "parent"
)
type RelationshipType string
type IDPrefixableResource interface {
PrefixedID() gidx.PrefixedID
}
type Relationships struct {
Relationships []Relationship
Memberships []ResourceMemberships
}
func (r Relationships) DeDupe() Relationships {
rels := make(map[string]bool)
mems := make(map[string]bool)
var results Relationships
for _, rel := range r.Relationships {
key := rel.Resource.PrefixedID().String() + "/" + string(rel.Relation) + "/" + rel.RelatedResource.PrefixedID().String()
if _, ok := rels[key]; !ok {
rels[key] = true
results.Relationships = append(results.Relationships, rel)
}
}
for _, member := range r.Memberships {
key := member.Resource.PrefixedID().String() + "/" + member.Role + "/" + member.Member.PrefixedID().String()
if _, ok := mems[key]; !ok {
mems[key] = true
results.Memberships = append(results.Memberships, member)
}
}
return results
}
type Relationship struct {
Resource IDPrefixableResource
Relation RelationshipType
RelatedResource IDPrefixableResource
}
type ResourceMemberships struct {
Resource IDPrefixableResource
Role string
Member *models.UserDetails
}

108
internal/service/service.go Normal file
View File

@@ -0,0 +1,108 @@
package service
import (
"context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
"go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
)
const (
// TypeOrganization defines the organization type.
TypeOrganization = "organization"
// TypeProject defines the project type.
TypeProject = "project"
// TypeUser defines the user type.
TypeUser = "user"
)
// DefaultPrefixMap is the default id prefix to type relationship.
var DefaultPrefixMap = map[string]string{
"metlorg": TypeOrganization,
"metlprj": TypeProject,
"metlusr": TypeUser,
}
// Service defines a bridge service methods
type Service interface {
// IsOrganizationID checks if the provided id has an id prefix which is an organization.
IsOrganizationID(id gidx.PrefixedID) bool
// TouchOrganization triggers a sync of an organization.
TouchOrganization(ctx context.Context, id gidx.PrefixedID) error
// DeleteOrganization deletes an organization and all of its resources.
DeleteOrganization(ctx context.Context, id gidx.PrefixedID) error
// IsProjectID checks if the provided id has an id prefix which is a project.
IsProjectID(id gidx.PrefixedID) bool
// TouchProject triggers a sync of an organization
TouchProject(ctx context.Context, id gidx.PrefixedID) error
// DeleteProject deletes the project and all of its resources.
DeleteProject(ctx context.Context, id gidx.PrefixedID) error
// 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
}
var _ Service = &service{}
type service struct {
logger *zap.SugaredLogger
publisher *events.Publisher
metal *metal.Client
perms *permissions.Client
idPrefixMap map[string]string
rootResource rootResource
roles map[string][]string
}
type rootResource struct {
id gidx.PrefixedID
}
func (r rootResource) PrefixedID() gidx.PrefixedID {
return r.id
}
func New(publisher *events.Publisher, metal *metal.Client, perms *permissions.Client, options ...Option) (Service, error) {
svc := &service{
publisher: publisher,
metal: metal,
perms: perms,
idPrefixMap: make(map[string]string),
}
for _, opt := range options {
if err := opt(svc); err != nil {
return nil, err
}
}
if svc.logger == nil {
svc.logger = zap.NewNop().Sugar()
}
if svc.rootResource.PrefixedID() == gidx.NullPrefixedID {
return nil, ErrRootTenantRequired
}
if svc.idPrefixMap == nil || len(svc.idPrefixMap) == 0 {
svc.idPrefixMap = DefaultPrefixMap
}
if svc.roles == nil {
svc.roles = make(map[string][]string)
}
return svc, nil
}

23
internal/service/users.go Normal file
View File

@@ -0,0 +1,23 @@
package service
import (
"context"
"go.infratographer.com/x/gidx"
)
func (s *service) IsUser(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeUser
}
return false
}
func (s *service) TouchUser(ctx context.Context, id gidx.PrefixedID) error {
return nil
}
func (s *service) DeleteUser(ctx context.Context, id gidx.PrefixedID) error {
return nil
}