Files
QSfera/Server/services/proxy/pkg/middleware/account_resolver_test.go
T
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

469 lines
14 KiB
Go

package middleware
import (
"context"
"net/http"
"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/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/proxy/pkg/config"
"github.com/qsfera/server/services/proxy/pkg/router"
"github.com/qsfera/server/services/proxy/pkg/user/backend"
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
userRoleMocks "github.com/qsfera/server/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"
testSvcAccountID = "svc-account-id"
testSvcAccountSecret = "svc-account-secret"
testSvcAccountToken = "svc-account-token"
)
func TestTokenIsAddedWithMailClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.Email, "mail", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.Email: "foo@example.com",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithUsernameClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDotUsernamePathClaim(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, "li.un", "username", false)
// This is how lico adds the username to the access token
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li": map[string]any{
"un": "foo",
},
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTokenIsAddedWithDottedUsernameClaim(t *testing.T) {
tests := []struct {
name string
oidcClaim string
// comment describing what the claim exercises
desc string
}{
{
name: "escaped dot treated as literal key",
oidcClaim: "li\\.un",
desc: "li\\.un escapes the dot so the claim is looked up as the literal key \"li.un\"",
},
{
name: "dotted path falls back to literal key",
oidcClaim: "li.un",
desc: "li.un is first tried as a nested path; when \"un\" is absent under \"li\", it falls back to the literal key \"li.un\"",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sut := newMockAccountResolver(&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Mail: "foo@example.com",
}, nil, tc.oidcClaim, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
"li.un": "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
})
}
}
func TestNSkipOnNoClaims(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.Email, "mail", false)
req, rw := mockRequest(nil)
sut.ServeHTTP(rw, req)
token := req.Header.Get("x-access-token")
assert.Empty(t, token)
assert.Equal(t, http.StatusOK, rw.Code)
}
func TestUnauthorizedOnUserNotFound(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestUnauthorizedOnUserDisabled(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.PreferredUsername, "username", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestInternalServerErrorOnMissingMailAndUsername(t *testing.T) {
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.Email, "mail", false)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusInternalServerError, rw.Code)
}
func TestUnauthorizedOnMissingTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.Empty(t, token)
assert.Equal(t, http.StatusUnauthorized, rw.Code)
}
func TestTokenIsAddedWhenUserHasTenantId(t *testing.T) {
sut := newMockAccountResolver(
&userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: "tenant1",
},
Username: "foo",
},
nil, oidc.PreferredUsername, "username", true)
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
})
sut.ServeHTTP(rw, req)
token := req.Header.Get(revactx.TokenHeader)
assert.NotEmpty(t, token)
assert.Contains(t, token, "eyJ")
}
func TestTenantClaimValidation(t *testing.T) {
tests := []struct {
name string
requestTenant string
wantToken bool
wantStatusCode int
}{
{
name: "token added when tenant claim matches",
requestTenant: testTenantA,
wantToken: true,
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized when tenant claim does not match",
requestTenant: testTenantB,
wantToken: false,
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
user := &userv1beta1.User{
Id: &userv1beta1.UserId{
Idp: testIdP,
OpaqueId: "123",
TenantId: testTenantA,
},
Username: "foo",
}
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
s, _ := scope.AddOwnerScope(nil)
token, _ := tokenManager.MintToken(context.Background(), user, s)
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)
sut := AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
UserOIDCClaim(oidc.PreferredUsername),
UserCS3Claim("username"),
TenantOIDCClaim("tenant_id"),
MultiTenantEnabled(true),
)(mockHandler{})
req, rw := mockRequest(map[string]any{
oidc.Iss: testIdP,
oidc.PreferredUsername: "foo",
"tenant_id": tc.requestTenant,
})
sut.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)
})
}
}
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string, multiTenant bool) http.Handler {
tokenManager, _ := jwt.New(map[string]any{
"secret": testJWTSecret,
"expires": int64(60),
})
token := ""
if userBackendResult != nil {
s, _ := scope.AddOwnerScope(nil)
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
}
ub := mocks.UserBackend{}
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, token, userBackendErr)
ub.On("GetUserRoles", mock.Anything, mock.Anything).Return(userBackendResult, nil)
ra := userRoleMocks.UserRoleAssigner{}
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, nil)
return AccountResolver(
Logger(log.NewLogger()),
UserProvider(&ub),
UserRoleAssigner(&ra),
SkipUserInfo(false),
UserOIDCClaim(oidcclaim),
UserCS3Claim(cs3claim),
AutoprovisionAccounts(false),
MultiTenantEnabled(multiTenant),
)(mockHandler{})
}
func mockRequest(claims map[string]any) (*http.Request, *httptest.ResponseRecorder) {
if claims == nil {
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
}
ctx := oidc.NewContext(context.Background(), claims)
ctx = router.SetRoutingInfo(ctx, router.RoutingInfo{})
req := httptest.NewRequest("GET", "http://example.com/foo", nil).WithContext(ctx)
rw := httptest.NewRecorder()
return req, rw
}
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]any{"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",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "qsfera.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]any{
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)
})
}
}