proxy: Allow mapping from an external tenant id to the internal id
When the tenant id coming in via the OIDC claims doesn't match the tenant id on the provisioned user, a mapping can be configured and resolved via the reva TenantAPI service (now started as part of the "users" service). Closes: #2310
This commit is contained in:
committed by
Ralf Haferkamp
parent
b8c4f581fb
commit
a931e53c26
@@ -1,12 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
|
||||
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/router"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/userroles"
|
||||
@@ -16,8 +20,10 @@ import (
|
||||
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/oidc"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -36,33 +42,39 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return &accountResolver{
|
||||
next: next,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
userProvider: options.UserProvider,
|
||||
userOIDCClaim: options.UserOIDCClaim,
|
||||
userCS3Claim: options.UserCS3Claim,
|
||||
tenantOIDCClaim: options.TenantOIDCClaim,
|
||||
userRoleAssigner: options.UserRoleAssigner,
|
||||
autoProvisionAccounts: options.AutoprovisionAccounts,
|
||||
multiTenantEnabled: options.MultiTenantEnabled,
|
||||
lastGroupSyncCache: lastGroupSyncCache,
|
||||
eventsPublisher: options.EventsPublisher,
|
||||
next: next,
|
||||
logger: logger,
|
||||
tracer: tracer,
|
||||
userProvider: options.UserProvider,
|
||||
userOIDCClaim: options.UserOIDCClaim,
|
||||
userCS3Claim: options.UserCS3Claim,
|
||||
tenantOIDCClaim: options.TenantOIDCClaim,
|
||||
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
|
||||
gatewaySelector: options.RevaGatewaySelector,
|
||||
serviceAccount: options.ServiceAccount,
|
||||
userRoleAssigner: options.UserRoleAssigner,
|
||||
autoProvisionAccounts: options.AutoprovisionAccounts,
|
||||
multiTenantEnabled: options.MultiTenantEnabled,
|
||||
lastGroupSyncCache: lastGroupSyncCache,
|
||||
eventsPublisher: options.EventsPublisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type accountResolver struct {
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
userProvider backend.UserBackend
|
||||
userRoleAssigner userroles.UserRoleAssigner
|
||||
autoProvisionAccounts bool
|
||||
multiTenantEnabled bool
|
||||
userOIDCClaim string
|
||||
userCS3Claim string
|
||||
tenantOIDCClaim string
|
||||
next http.Handler
|
||||
logger log.Logger
|
||||
tracer trace.Tracer
|
||||
userProvider backend.UserBackend
|
||||
userRoleAssigner userroles.UserRoleAssigner
|
||||
autoProvisionAccounts bool
|
||||
multiTenantEnabled bool
|
||||
tenantIDMappingEnabled bool
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
serviceAccount config.ServiceAccount
|
||||
userOIDCClaim string
|
||||
userCS3Claim string
|
||||
tenantOIDCClaim string
|
||||
// lastGroupSyncCache is used to keep track of when the last sync of group
|
||||
// memberships was done for a specific user. This is used to trigger a sync
|
||||
// with every single request.
|
||||
@@ -173,7 +185,7 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// if a tenant claim is configured, verify it matches the tenant id on the resolved user
|
||||
if m.tenantOIDCClaim != "" {
|
||||
if err = m.verifyTenantClaim(user.GetId().GetTenantId(), claims); err != nil {
|
||||
if err = m.verifyTenantClaim(req.Context(), user.GetId().GetTenantId(), claims); err != nil {
|
||||
m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Tenant claim mismatch")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
@@ -260,13 +272,47 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
m.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func (m accountResolver) verifyTenantClaim(userTenantID string, claims map[string]interface{}) error {
|
||||
func (m accountResolver) verifyTenantClaim(ctx context.Context, userTenantID string, claims map[string]interface{}) error {
|
||||
claimTenantID, err := readStringClaim(m.tenantOIDCClaim, claims)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read tenant claim: %w", err)
|
||||
}
|
||||
if claimTenantID != userTenantID {
|
||||
|
||||
internalTenantID := claimTenantID
|
||||
if m.tenantIDMappingEnabled {
|
||||
internalTenantID, err = m.resolveInternalTenantID(ctx, claimTenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve internal tenant id for external tenant id %q: %w", claimTenantID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if internalTenantID != userTenantID {
|
||||
return fmt.Errorf("tenant id from claim %q does not match user tenant id %q", claimTenantID, userTenantID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInternalTenantID calls the gateway's TenantAPI to map an external tenant ID (as it
|
||||
// appears in OIDC claims) to the internal tenant ID stored on the user object.
|
||||
// The call is authenticated using the configured service account.
|
||||
func (m accountResolver) resolveInternalTenantID(ctx context.Context, externalTenantID string) (string, error) {
|
||||
gwc, err := m.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get gateway client: %w", err)
|
||||
}
|
||||
authCtx, err := utils.GetServiceUserContextWithContext(ctx, gwc, m.serviceAccount.ServiceAccountID, m.serviceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not authenticate service account: %w", err)
|
||||
}
|
||||
resp, err := gwc.GetTenantByClaim(authCtx, &tenantpb.GetTenantByClaimRequest{
|
||||
Claim: "externalid",
|
||||
Value: externalTenantID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.GetStatus().GetCode() != rpcpb.Code_CODE_OK {
|
||||
return "", fmt.Errorf("TenantAPI returned status %s: %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
|
||||
}
|
||||
return resp.GetTenant().GetId(), nil
|
||||
}
|
||||
|
||||
@@ -6,25 +6,35 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/oidc"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/router"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend/mocks"
|
||||
userRoleMocks "github.com/opencloud-eu/opencloud/services/proxy/pkg/userroles/mocks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
testIdP = "https://idx.example.com"
|
||||
testTenantA = "tenant-a"
|
||||
testTenantB = "tenant-b"
|
||||
testJWTSecret = "change-me"
|
||||
testIdP = "https://idx.example.com"
|
||||
testTenantA = "tenant-a"
|
||||
testTenantB = "tenant-b"
|
||||
testJWTSecret = "change-me"
|
||||
testSvcAccountID = "svc-account-id"
|
||||
testSvcAccountSecret = "svc-account-secret"
|
||||
testSvcAccountToken = "svc-account-token"
|
||||
)
|
||||
|
||||
func TestTokenIsAddedWithMailClaim(t *testing.T) {
|
||||
@@ -336,3 +346,123 @@ func mockRequest(claims map[string]interface{}) (*http.Request, *httptest.Respon
|
||||
type mockHandler struct{}
|
||||
|
||||
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
|
||||
|
||||
func TestTenantIDMapping(t *testing.T) {
|
||||
const (
|
||||
externalTenantID = "external-tenant-x"
|
||||
internalTenantID = testTenantA
|
||||
)
|
||||
|
||||
user := &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: testIdP,
|
||||
OpaqueId: "123",
|
||||
TenantId: internalTenantID,
|
||||
},
|
||||
Username: "foo",
|
||||
}
|
||||
|
||||
tokenManager, _ := jwt.New(map[string]interface{}{"secret": testJWTSecret, "expires": int64(60)})
|
||||
s, _ := scope.AddOwnerScope(nil)
|
||||
token, _ := tokenManager.MintToken(context.Background(), user, s)
|
||||
|
||||
newSUT := func(t *testing.T, gatewayClient gateway.GatewayAPIClient) http.Handler {
|
||||
t.Helper()
|
||||
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"eu.opencloud.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "eu.opencloud.api.gateway") })
|
||||
|
||||
ub := mocks.UserBackend{}
|
||||
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
|
||||
ra := userRoleMocks.UserRoleAssigner{}
|
||||
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
||||
|
||||
return AccountResolver(
|
||||
Logger(log.NewLogger()),
|
||||
UserProvider(&ub),
|
||||
UserRoleAssigner(&ra),
|
||||
UserOIDCClaim(oidc.PreferredUsername),
|
||||
UserCS3Claim("username"),
|
||||
TenantOIDCClaim("tenant_id"),
|
||||
MultiTenantEnabled(true),
|
||||
TenantIDMappingEnabled(true),
|
||||
ServiceAccount(config.ServiceAccount{
|
||||
ServiceAccountID: testSvcAccountID,
|
||||
ServiceAccountSecret: testSvcAccountSecret,
|
||||
}),
|
||||
WithRevaGatewaySelector(gatewaySelector),
|
||||
)(mockHandler{})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tenantResponse *tenantpb.GetTenantByClaimResponse
|
||||
wantToken bool
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "token added when external tenant maps to user internal tenant",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Tenant: &tenantpb.Tenant{Id: internalTenantID, ExternalId: externalTenantID},
|
||||
},
|
||||
wantToken: true,
|
||||
wantStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "unauthorized when external tenant maps to a different internal tenant",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Tenant: &tenantpb.Tenant{Id: testTenantB, ExternalId: externalTenantID},
|
||||
},
|
||||
wantToken: false,
|
||||
wantStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "unauthorized when external tenant is not found",
|
||||
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_NOT_FOUND, Message: "not found"},
|
||||
},
|
||||
wantToken: false,
|
||||
wantStatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gwc := &cs3mocks.GatewayAPIClient{}
|
||||
gwc.On("Authenticate", mock.Anything, &gateway.AuthenticateRequest{
|
||||
Type: "serviceaccounts",
|
||||
ClientId: testSvcAccountID,
|
||||
ClientSecret: testSvcAccountSecret,
|
||||
}).Return(&gateway.AuthenticateResponse{
|
||||
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
||||
Token: testSvcAccountToken,
|
||||
}, nil)
|
||||
gwc.On("GetTenantByClaim", mock.Anything, &tenantpb.GetTenantByClaimRequest{
|
||||
Claim: "externalid",
|
||||
Value: externalTenantID,
|
||||
}).Return(tc.tenantResponse, nil)
|
||||
|
||||
req, rw := mockRequest(map[string]interface{}{
|
||||
oidc.Iss: testIdP,
|
||||
oidc.PreferredUsername: "foo",
|
||||
"tenant_id": externalTenantID,
|
||||
})
|
||||
newSUT(t, gwc).ServeHTTP(rw, req)
|
||||
|
||||
if tc.wantToken {
|
||||
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
|
||||
} else {
|
||||
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
|
||||
}
|
||||
assert.Equal(t, tc.wantStatusCode, rw.Code)
|
||||
gwc.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ type Options struct {
|
||||
SkipUserInfo bool
|
||||
// MultiTenantEnabled causes the account resolve middleware to reject users that don't have a tenant id assigned
|
||||
MultiTenantEnabled bool
|
||||
EventsPublisher events.Publisher
|
||||
// TenantIDMappingEnabled causes the account resolver to resolve the internal tenant ID from the external
|
||||
// tenant ID in the OIDC claims via the gateway's TenantAPI before comparing it to the user's stored tenant ID.
|
||||
TenantIDMappingEnabled bool
|
||||
// ServiceAccount holds credentials used to authenticate internal service calls (e.g. TenantAPI lookups).
|
||||
ServiceAccount config.ServiceAccount
|
||||
EventsPublisher events.Publisher
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
@@ -258,6 +263,22 @@ func MultiTenantEnabled(val bool) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceAccount sets the service account credentials used for authenticated internal calls.
|
||||
func ServiceAccount(sa config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.ServiceAccount = sa
|
||||
}
|
||||
}
|
||||
|
||||
// TenantIDMappingEnabled sets the TenantIDMappingEnabled flag.
|
||||
// When true, the account resolver resolves the internal tenant ID from the external tenant ID
|
||||
// provided in the OIDC claims by calling the gateway's TenantAPI, instead of comparing directly.
|
||||
func TenantIDMappingEnabled(val bool) Option {
|
||||
return func(o *Options) {
|
||||
o.TenantIDMappingEnabled = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventsPublisher sets the events publisher.
|
||||
func EventsPublisher(ep events.Publisher) Option {
|
||||
return func(o *Options) {
|
||||
|
||||
Reference in New Issue
Block a user