Skip to content

Commit

Permalink
Support for multiple issuers / generic token issuer (#176)
Browse files Browse the repository at this point in the history
  • Loading branch information
droid42 authored Mar 23, 2021
1 parent e448777 commit 30de7eb
Show file tree
Hide file tree
Showing 6 changed files with 557 additions and 38 deletions.
97 changes: 97 additions & 0 deletions cmd/metal-api/internal/service/tenant-service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package service

import (
"context"
"net/http"

"github.com/metal-stack/masterdata-api/api/rest/mapper"
v1 "github.com/metal-stack/masterdata-api/api/rest/v1"
mdmv1 "github.com/metal-stack/masterdata-api/api/v1"
mdm "github.com/metal-stack/masterdata-api/pkg/client"

"github.com/metal-stack/metal-api/cmd/metal-api/internal/utils"
"go.uber.org/zap"

restfulspec "github.com/emicklei/go-restful-openapi/v2"
restful "github.com/emicklei/go-restful/v3"
"github.com/metal-stack/metal-lib/httperrors"
"github.com/metal-stack/metal-lib/zapup"
)

type tenantResource struct {
mdc mdm.Client
}

// NewTenant returns a webservice for tenant specific endpoints.
func NewTenant(mdc mdm.Client) *restful.WebService {
r := tenantResource{
mdc: mdc,
}
return r.webService()
}

func (r tenantResource) webService() *restful.WebService {
ws := new(restful.WebService)
ws.
Path(BasePath + "v1/tenant").
Consumes(restful.MIME_JSON).
Produces(restful.MIME_JSON)

tags := []string{"tenant"}

ws.Route(ws.GET("/{id}").
To(viewer(r.getTenant)).
Operation("getTenant").
Doc("get tenant by id").
Param(ws.PathParameter("id", "identifier of the tenant").DataType("string")).
Metadata(restfulspec.KeyOpenAPITags, tags).
Writes(v1.TenantResponse{}).
Returns(http.StatusOK, "OK", v1.TenantResponse{}).
DefaultReturns("Error", httperrors.HTTPErrorResponse{}))

ws.Route(ws.GET("/").
To(viewer(r.listTenants)).
Operation("listTenants").
Doc("get all tenants").
Metadata(restfulspec.KeyOpenAPITags, tags).
Writes([]v1.TenantResponse{}).
Returns(http.StatusOK, "OK", []v1.TenantResponse{}).
DefaultReturns("Error", httperrors.HTTPErrorResponse{}))

return ws
}

func (r tenantResource) getTenant(request *restful.Request, response *restful.Response) {
id := request.PathParameter("id")

tres, err := r.mdc.Tenant().Get(context.Background(), &mdmv1.TenantGetRequest{Id: id})
if checkError(request, response, utils.CurrentFuncName(), err) {
return
}

v1t := mapper.ToV1Tenant(tres.Tenant)
err = response.WriteHeaderAndEntity(http.StatusOK, &v1t)
if err != nil {
zapup.MustRootLogger().Error("Failed to send response", zap.Error(err))
return
}
}

func (r tenantResource) listTenants(request *restful.Request, response *restful.Response) {
tres, err := r.mdc.Tenant().Find(context.Background(), &mdmv1.TenantFindRequest{})
if checkError(request, response, utils.CurrentFuncName(), err) {
return
}

var v1ts []*v1.Tenant
for _, t := range tres.Tenants {
v1t := mapper.ToV1Tenant(t)
v1ts = append(v1ts, v1t)
}

err = response.WriteHeaderAndEntity(http.StatusOK, v1ts)
if err != nil {
zapup.MustRootLogger().Error("Failed to send response", zap.Error(err))
return
}
}
170 changes: 170 additions & 0 deletions cmd/metal-api/internal/service/tenant-service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package service

import (
"context"
"fmt"
"testing"

restful "github.com/emicklei/go-restful/v3"
v1 "github.com/metal-stack/masterdata-api/api/rest/v1"
mdmv1 "github.com/metal-stack/masterdata-api/api/v1"
mdmv1mock "github.com/metal-stack/masterdata-api/api/v1/mocks"
mdm "github.com/metal-stack/masterdata-api/pkg/client"
"github.com/metal-stack/metal-lib/httperrors"
"github.com/metal-stack/security"
"github.com/stretchr/testify/require"
)

type MockedTenantService struct {
t *testing.T
ws *restful.WebService
}

func NewMockedTenantService(t *testing.T, tenantServiceMock func(mock *mdmv1mock.TenantServiceClient)) *MockedTenantService {
tsc := &mdmv1mock.TenantServiceClient{}
if tenantServiceMock != nil {
tenantServiceMock(tsc)
}
mdc := mdm.NewMock(&mdmv1mock.ProjectServiceClient{}, tsc)
ws := NewTenant(mdc)
return &MockedTenantService{
t: t,
ws: ws,
}
}

//nolint:golint,unused
func (m *MockedTenantService) list(user *security.User, resp interface{}) int {
return webRequestGet(m.t, m.ws, user, user, "/v1/tenant", resp)
}

func (m *MockedTenantService) get(id string, user *security.User, resp interface{}) int {
return webRequestGet(m.t, m.ws, user, user, "/v1/tenant/"+id, resp)
}

func Test_tenantResource_getTenant(t *testing.T) {

tests := []struct {
name string
userScenarios []security.User
tenantServiceMock func(mock *mdmv1mock.TenantServiceClient)
id string
wantStatus int
want *v1.TenantResponse
wantErr *httperrors.HTTPErrorResponse
}{
{
name: "entity forbidden for user with no privileges",
userScenarios: []security.User{*noUser},
id: "121",
wantStatus: 403,
wantErr: httperrors.Forbidden(fmt.Errorf("you are not member in one of [k8s_kaas-view maas-all-all-view k8s_kaas-edit maas-all-all-edit k8s_kaas-admin maas-all-all-admin]")),
},
{
name: "entity allowed for user with view privileges",
userScenarios: []security.User{*testViewUser},
id: "122",
tenantServiceMock: func(mock *mdmv1mock.TenantServiceClient) {
mock.On("Get", context.Background(), &mdmv1.TenantGetRequest{Id: "122"}).Return(&mdmv1.TenantResponse{Tenant: &mdmv1.Tenant{Name: "t122"}}, nil)
},
want: &v1.TenantResponse{Tenant: v1.Tenant{Name: "t122"}},
wantStatus: 200,
wantErr: nil,
},
{
name: "entity allowed for user with admin privileges",
userScenarios: []security.User{*testAdminUser},
tenantServiceMock: func(mock *mdmv1mock.TenantServiceClient) {
mock.On("Get", context.Background(), &mdmv1.TenantGetRequest{Id: "123"}).Return(&mdmv1.TenantResponse{Tenant: &mdmv1.Tenant{Name: "t123"}}, nil)
},
id: "123",
want: &v1.TenantResponse{Tenant: v1.Tenant{Name: "t123"}},
wantStatus: 200,
wantErr: nil,
},
}
for _, tt := range tests {
tt := tt
for _, user := range tt.userScenarios {
user := user
name := fmt.Sprintf("%s/%s", tt.name, user)
t.Run(name, func(t *testing.T) {
service := NewMockedTenantService(t, tt.tenantServiceMock)
if tt.wantErr != nil {
got := httperrors.HTTPErrorResponse{}
status := service.get(tt.id, &user, &got)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, *tt.wantErr, got)
return
}

got := v1.TenantResponse{}
status := service.get(tt.id, &user, &got)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, *tt.want, got)
})
}
}
}

func Test_tenantResource_listTenants(t *testing.T) {

tests := []struct {
name string
userScenarios []security.User
tenantServiceMock func(mock *mdmv1mock.TenantServiceClient)
wantStatus int
want []*v1.Tenant
wantErr *httperrors.HTTPErrorResponse
}{
{
name: "entity forbidden for user with no privileges",
userScenarios: []security.User{*noUser},
wantStatus: 403,
wantErr: httperrors.Forbidden(fmt.Errorf("you are not member in one of [k8s_kaas-view maas-all-all-view k8s_kaas-edit maas-all-all-edit k8s_kaas-admin maas-all-all-admin]")),
},
{
name: "entity allowed for user with view privileges",
userScenarios: []security.User{*testViewUser},
tenantServiceMock: func(mock *mdmv1mock.TenantServiceClient) {
mock.On("Find", context.Background(), &mdmv1.TenantFindRequest{}).Return(&mdmv1.TenantListResponse{Tenants: []*mdmv1.Tenant{{Name: "t121"}, {Name: "t122"}}}, nil)
},
want: []*v1.Tenant{{Name: "t121"}, {Name: "t122"}},
wantStatus: 200,
wantErr: nil,
},
{
name: "entity allowed for user with admin privileges",
userScenarios: []security.User{*testAdminUser},
tenantServiceMock: func(mock *mdmv1mock.TenantServiceClient) {
mock.On("Find", context.Background(), &mdmv1.TenantFindRequest{}).Return(&mdmv1.TenantListResponse{Tenants: []*mdmv1.Tenant{{Name: "t123"}}}, nil)
},
want: []*v1.Tenant{{Name: "t123"}},
wantStatus: 200,
wantErr: nil,
},
}
for _, tt := range tests {
tt := tt
for _, user := range tt.userScenarios {
user := user
name := fmt.Sprintf("%s/%s", tt.name, user)
t.Run(name, func(t *testing.T) {
service := NewMockedTenantService(t, tt.tenantServiceMock)

if tt.wantErr != nil {
got := httperrors.HTTPErrorResponse{}
status := service.list(&user, &got)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, *tt.wantErr, got)
return
}

var got []*v1.Tenant
status := service.list(&user, &got)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, tt.want, got)
})
}
}
}
72 changes: 70 additions & 2 deletions cmd/metal-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
v1 "github.com/metal-stack/masterdata-api/api/v1"
"github.com/metal-stack/metal-api/cmd/metal-api/internal/service/s3client"
"google.golang.org/protobuf/types/known/wrapperspb"
"net/http"
httppprof "net/http/pprof"
"os"
Expand All @@ -13,6 +15,8 @@ import (
"syscall"
"time"

"github.com/go-logr/zapr"

"github.com/metal-stack/metal-api/cmd/metal-api/internal/grpc"
"github.com/metal-stack/metal-api/cmd/metal-api/internal/metrics"
"github.com/metal-stack/metal-lib/rest"
Expand Down Expand Up @@ -242,6 +246,7 @@ func init() {
rootCmd.Flags().StringP("hmac-admin-lifetime", "", "90s", "the timestamp in the header for the HMAC must not be older than this value. a value of 0 means no limit")

rootCmd.Flags().StringP("provider-tenant", "", "", "the tenant of the maas-provider who operates the whole thing")
rootCmd.Flags().StringP("issuercache-interval", "", "30m", "issuercache invalidation interval, e.g. 60s, 30m, 2h45m - default 30m")

rootCmd.Flags().StringP("masterdata-hmac", "", "must-be-changed", "the preshared key for hmac security to talk to the masterdata-api")
rootCmd.Flags().StringP("masterdata-hostname", "", "", "the hostname of the masterdata-api")
Expand Down Expand Up @@ -518,6 +523,63 @@ func initAuth(lg *zap.SugaredLogger) security.UserGetter {

providerTenant := viper.GetString("provider-tenant")

grpr, err := grp.NewGrpr(grp.Config{ProviderTenant: providerTenant})
if err != nil {
logger.Fatalw("error creating grpr", "error", err)
}
plugin := sec.NewPlugin(grpr)

issuerCacheInterval, err := time.ParseDuration(viper.GetString("issuercache-interval"))
if err != nil {
logger.Fatalw("error parsing issuercache-interval", "error", err)
}

// create multi issuer cache that holds all trusted issuers from masterdata, in this case: only provider tenant
issuerCache, err := security.NewMultiIssuerCache(func() ([]*security.IssuerConfig, error) {
logger.Infow("loading tenants for issuercache", "providerTenant", providerTenant)

// get provider tenant from masterdata
ts, err := mdc.Tenant().Find(context.Background(), &v1.TenantFindRequest{
Id: wrapperspb.String(providerTenant),
})
if err != nil {
return nil, err
}

if len(ts.Tenants) != 1 {
return nil, fmt.Errorf("no masterdata for tenant %s found", providerTenant)
}

t := ts.Tenants[0]
if t.IamConfig != nil {
directory := ""
if t.IamConfig.IdmConfig != nil {
directory = t.IamConfig.IdmConfig.IdmType
}
tenantID := t.Meta.Id
return []*security.IssuerConfig{
{
Annotations: map[string]string{
sec.OidcDirectory: directory,
},
Tenant: tenantID,
Issuer: t.IamConfig.IssuerConfig.Url,
ClientID: t.IamConfig.IssuerConfig.ClientId,
},
}, nil
}
return []*security.IssuerConfig{}, nil
}, func(ic *security.IssuerConfig) (security.UserGetter, error) {
return security.NewGenericOIDC(ic, security.GenericUserExtractor(plugin.GenericOIDCExtractUserProcessGroups))
}, security.IssuerReloadInterval(issuerCacheInterval), security.Logger(zapr.NewLogger(logger.Desugar())))

if err != nil || issuerCache == nil {
logger.Fatalw("error creating dynamic oidc resolver", "error", err)
}
logger.Info("dynamic oidc resolver successfully initialized")

var ugsOpts []security.UserGetterProxyOption
dexClientID := viper.GetString("dex-clientid")
dexAddr := viper.GetString("dex-addr")
if dexAddr != "" {
dx, err := security.NewDex(dexAddr)
Expand All @@ -526,15 +588,20 @@ func initAuth(lg *zap.SugaredLogger) security.UserGetter {
}
if dx != nil {
// use custom user extractor and group processor
plugin := sec.NewPlugin(grp.MustNewGrpr(grp.Config{ProviderTenant: providerTenant}))
dx.With(security.UserExtractor(plugin.ExtractUserProcessGroups))
auths = append(auths, security.WithDex(dx))
ugsOpts = append(ugsOpts, security.UserGetterProxyMapping(dexAddr, dexClientID, dx))
logger.Info("dex successfully configured")
} else {
logger.Fatal("dex is configured, but not initialized")
}
}

// UserGetterProxy with dynamic oidc as default and legacy dex as explicit mapping
ugp := security.NewUserGetterProxy(issuerCache, ugsOpts...)

// add UserGetterProxy as CredsOpt
auths = append(auths, security.WithDex(ugp))

defaultUsers := service.NewUserDirectory(providerTenant)
for _, u := range defaultUsers.UserNames() {
lfkey := fmt.Sprintf("hmac-%s-lifetime", u)
Expand Down Expand Up @@ -634,6 +701,7 @@ func initRestServices(withauth bool) *restfulspec.Config {
restful.DefaultContainer.Add(firmwareService)
restful.DefaultContainer.Add(machineService)
restful.DefaultContainer.Add(service.NewProject(ds, mdc))
restful.DefaultContainer.Add(service.NewTenant(mdc))
restful.DefaultContainer.Add(firewallService)
restful.DefaultContainer.Add(service.NewSwitch(ds))
restful.DefaultContainer.Add(rest.NewHealth(lg, service.BasePath, ds.Health))
Expand Down
Loading

0 comments on commit 30de7eb

Please sign in to comment.