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:
Ralf Haferkamp
2026-04-09 17:46:50 +02:00
committed by Ralf Haferkamp
parent b8c4f581fb
commit a931e53c26
8 changed files with 252 additions and 30 deletions
@@ -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)
})
}
}
+22 -1
View File
@@ -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) {