initial commit
This commit is contained in:
31
internal/config/config.go
Normal file
31
internal/config/config.go
Normal 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
15
internal/metal/config.go
Normal 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
2
internal/metal/doc.go
Normal 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
8
internal/metal/errors.go
Normal 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
46
internal/metal/metal.go
Normal 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
|
||||
}
|
||||
12
internal/metal/models/idprefix.go
Normal file
12
internal/metal/models/idprefix.go
Normal 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"
|
||||
)
|
||||
8
internal/metal/models/memberships.go
Normal file
8
internal/metal/models/memberships.go
Normal 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"`
|
||||
}
|
||||
18
internal/metal/models/organizations.go
Normal file
18
internal/metal/models/organizations.go
Normal 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)
|
||||
}
|
||||
18
internal/metal/models/projects.go
Normal file
18
internal/metal/models/projects.go
Normal 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)
|
||||
}
|
||||
23
internal/metal/models/users.go
Normal file
23
internal/metal/models/users.go
Normal 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
60
internal/metal/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
134
internal/metal/providers/emapi/client.go
Normal file
134
internal/metal/providers/emapi/client.go
Normal 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
|
||||
}
|
||||
81
internal/metal/providers/emapi/config.go
Normal file
81
internal/metal/providers/emapi/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
5
internal/metal/providers/emapi/errors.go
Normal file
5
internal/metal/providers/emapi/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package emapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrBaseURLRequired = errors.New("emapi base url required")
|
||||
84
internal/metal/providers/emapi/memberships.go
Normal file
84
internal/metal/providers/emapi/memberships.go
Normal 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,
|
||||
}
|
||||
}
|
||||
17
internal/metal/providers/emapi/options.go
Normal file
17
internal/metal/providers/emapi/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
84
internal/metal/providers/emapi/organizations.go
Normal file
84
internal/metal/providers/emapi/organizations.go
Normal 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
|
||||
}
|
||||
84
internal/metal/providers/emapi/projects.go
Normal file
84
internal/metal/providers/emapi/projects.go
Normal 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
|
||||
}
|
||||
80
internal/metal/providers/emapi/users.go
Normal file
80
internal/metal/providers/emapi/users.go
Normal 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
|
||||
}
|
||||
49
internal/metal/providers/emgql/client.go
Normal file
49
internal/metal/providers/emgql/client.go
Normal 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
|
||||
}
|
||||
12
internal/metal/providers/emgql/config.go
Normal file
12
internal/metal/providers/emgql/config.go
Normal 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 != ""
|
||||
}
|
||||
52
internal/metal/providers/emgql/options.go
Normal file
52
internal/metal/providers/emgql/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
12
internal/metal/providers/emgql/organizations.go
Normal file
12
internal/metal/providers/emgql/organizations.go
Normal 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
|
||||
}
|
||||
12
internal/metal/providers/emgql/projects.go
Normal file
12
internal/metal/providers/emgql/projects.go
Normal 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
|
||||
}
|
||||
12
internal/metal/providers/emgql/users.go
Normal file
12
internal/metal/providers/emgql/users.go
Normal 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
|
||||
}
|
||||
14
internal/metal/providers/provider.go
Normal file
14
internal/metal/providers/provider.go
Normal 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)
|
||||
}
|
||||
84
internal/permissions/assignments.go
Normal file
84
internal/permissions/assignments.go
Normal 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
|
||||
}
|
||||
128
internal/permissions/client.go
Normal file
128
internal/permissions/client.go
Normal 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
|
||||
}
|
||||
73
internal/permissions/config.go
Normal file
73
internal/permissions/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
8
internal/permissions/errors.go
Normal file
8
internal/permissions/errors.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package permissions
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrRoleNotFound = errors.New("role not found")
|
||||
ErrAssignmentFailed = errors.New("assignment failed")
|
||||
)
|
||||
15
internal/permissions/options.go
Normal file
15
internal/permissions/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
82
internal/permissions/roles.go
Normal file
82
internal/permissions/roles.go
Normal 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
2
internal/pubsub/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package pubsub provides functions and data for a NATS consumer listening for resource lifecycle events.
|
||||
package pubsub
|
||||
220
internal/pubsub/subscriber.go
Normal file
220
internal/pubsub/subscriber.go
Normal 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
22
internal/routes/errors.go
Normal 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")
|
||||
)
|
||||
27
internal/routes/options.go
Normal file
27
internal/routes/options.go
Normal 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
32
internal/routes/routes.go
Normal 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
|
||||
}
|
||||
9
internal/service/config.go
Normal file
9
internal/service/config.go
Normal 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
2
internal/service/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package service handles integrating Equinix Metal with Infratographer
|
||||
package service
|
||||
14
internal/service/errors.go
Normal file
14
internal/service/errors.go
Normal 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")
|
||||
)
|
||||
90
internal/service/options.go
Normal file
90
internal/service/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
101
internal/service/organizations.go
Normal file
101
internal/service/organizations.go
Normal 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
137
internal/service/process.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
internal/service/projects.go
Normal file
23
internal/service/projects.go
Normal 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
|
||||
}
|
||||
64
internal/service/relationships.go
Normal file
64
internal/service/relationships.go
Normal 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
108
internal/service/service.go
Normal 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
23
internal/service/users.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user