mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 12:09:58 +00:00
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) — port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
429 lines
14 KiB
Go
429 lines
14 KiB
Go
//go:build integration
|
|
|
|
package grpc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/proxy/auth"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
type validateSessionTestSetup struct {
|
|
proxyService *ProxyServiceServer
|
|
store store.Store
|
|
cleanup func()
|
|
}
|
|
|
|
func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
testStore, storeCleanup, err := store.NewTestStoreFromSQL(ctx, "../../../server/testdata/auth_callback.sql", t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
serviceManager := &testValidateSessionServiceManager{store: testStore}
|
|
usersManager := &testValidateSessionUsersManager{store: testStore}
|
|
proxyManager := &testValidateSessionProxyManager{}
|
|
|
|
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
|
|
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
|
|
|
|
proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager, nil)
|
|
proxyService.SetServiceManager(serviceManager)
|
|
|
|
createTestProxies(t, ctx, testStore)
|
|
|
|
return &validateSessionTestSetup{
|
|
proxyService: proxyService,
|
|
store: testStore,
|
|
cleanup: storeCleanup,
|
|
}
|
|
}
|
|
|
|
func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store) {
|
|
t.Helper()
|
|
|
|
pubKey, privKey := generateSessionKeyPair(t)
|
|
|
|
testProxy := &service.Service{
|
|
ID: "testProxyId",
|
|
AccountID: "testAccountId",
|
|
Name: "Test Proxy",
|
|
Domain: "test-proxy.example.com",
|
|
Enabled: true,
|
|
SessionPrivateKey: privKey,
|
|
SessionPublicKey: pubKey,
|
|
Auth: service.AuthConfig{
|
|
BearerAuth: &service.BearerAuthConfig{
|
|
Enabled: true,
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, testStore.CreateService(ctx, testProxy))
|
|
|
|
restrictedProxy := &service.Service{
|
|
ID: "restrictedProxyId",
|
|
AccountID: "testAccountId",
|
|
Name: "Restricted Proxy",
|
|
Domain: "restricted-proxy.example.com",
|
|
Enabled: true,
|
|
SessionPrivateKey: privKey,
|
|
SessionPublicKey: pubKey,
|
|
Auth: service.AuthConfig{
|
|
BearerAuth: &service.BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: []string{"allowedGroupId"},
|
|
},
|
|
},
|
|
}
|
|
require.NoError(t, testStore.CreateService(ctx, restrictedProxy))
|
|
}
|
|
|
|
func generateSessionKeyPair(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(priv)
|
|
}
|
|
|
|
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
|
|
t.Helper()
|
|
token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
|
|
require.NoError(t, err)
|
|
return token
|
|
}
|
|
|
|
func TestValidateSession_UserAllowed(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId")
|
|
require.NoError(t, err)
|
|
|
|
token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com")
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "test-proxy.example.com",
|
|
SessionToken: token,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.True(t, resp.Valid, "User should be allowed access")
|
|
assert.Equal(t, "allowedUserId", resp.UserId)
|
|
assert.Empty(t, resp.DeniedReason)
|
|
assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships")
|
|
}
|
|
|
|
func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
|
|
require.NoError(t, err)
|
|
|
|
token := createSessionToken(t, proxy.SessionPrivateKey, "nonGroupUserId", "restricted-proxy.example.com")
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "restricted-proxy.example.com",
|
|
SessionToken: token,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid, "User not in group should be denied")
|
|
assert.Equal(t, "not_in_group", resp.DeniedReason)
|
|
assert.Equal(t, "nonGroupUserId", resp.UserId)
|
|
assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
|
|
}
|
|
|
|
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId")
|
|
require.NoError(t, err)
|
|
|
|
token := createSessionToken(t, proxy.SessionPrivateKey, "otherAccountUserId", "test-proxy.example.com")
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "test-proxy.example.com",
|
|
SessionToken: token,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid, "User in different account should be denied")
|
|
assert.Equal(t, "account_mismatch", resp.DeniedReason)
|
|
}
|
|
|
|
func TestValidateSession_UserNotFound(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId")
|
|
require.NoError(t, err)
|
|
|
|
token := createSessionToken(t, proxy.SessionPrivateKey, "nonExistentUserId", "test-proxy.example.com")
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "test-proxy.example.com",
|
|
SessionToken: token,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid, "Non-existent user should be denied")
|
|
assert.Equal(t, "user_not_found", resp.DeniedReason)
|
|
}
|
|
|
|
func TestValidateSession_ProxyNotFound(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId")
|
|
require.NoError(t, err)
|
|
|
|
token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "unknown-proxy.example.com")
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "unknown-proxy.example.com",
|
|
SessionToken: token,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid, "Unknown proxy should be denied")
|
|
assert.Equal(t, "service_not_found", resp.DeniedReason)
|
|
}
|
|
|
|
func TestValidateSession_InvalidToken(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "test-proxy.example.com",
|
|
SessionToken: "invalid-token",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid, "Invalid token should be denied")
|
|
assert.Equal(t, "invalid_token", resp.DeniedReason)
|
|
}
|
|
|
|
func TestValidateSession_MissingDomain(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
SessionToken: "some-token",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid)
|
|
assert.Contains(t, resp.DeniedReason, "missing")
|
|
}
|
|
|
|
func TestValidateSession_MissingToken(t *testing.T) {
|
|
setup := setupValidateSessionTest(t)
|
|
defer setup.cleanup()
|
|
|
|
resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{
|
|
Domain: "test-proxy.example.com",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, resp.Valid)
|
|
assert.Contains(t, resp.DeniedReason, "missing")
|
|
}
|
|
|
|
type testValidateSessionServiceManager struct {
|
|
store store.Store
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) DeleteService(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) DeleteAllServices(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) ReloadService(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) {
|
|
return m.store.GetServices(ctx, store.LockingStrengthNone)
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) {
|
|
return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID)
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
|
|
return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) StartExposeReaper(_ context.Context) {}
|
|
|
|
func (m *testValidateSessionServiceManager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) {
|
|
return m.store.GetServiceByDomain(ctx, domain)
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) GetClusters(_ context.Context, _, _ string) ([]proxy.Cluster, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
type testValidateSessionProxyManager struct{}
|
|
|
|
func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) Disconnect(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) Heartbeat(_ context.Context, _ *proxy.Proxy) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) DeleteAccountCluster(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) GetActiveClusterAddresses(_ context.Context) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) GetActiveClusterAddressesForAccount(_ context.Context, _ string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) GetActiveClusters(_ context.Context) ([]proxy.Cluster, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) GetAccountProxy(_ context.Context, _ string) (*proxy.Proxy, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) CountAccountProxies(_ context.Context, _ string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) IsClusterAddressAvailable(_ context.Context, _, _ string) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) DeleteProxy(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) ClusterSupportsCustomPorts(_ context.Context, _ string) *bool {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) ClusterRequireSubdomain(_ context.Context, _ string) *bool {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
|
|
return nil
|
|
}
|
|
|
|
func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
|
|
return nil
|
|
}
|
|
|
|
type testValidateSessionUsersManager struct {
|
|
store store.Store
|
|
}
|
|
|
|
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
|
|
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
|
}
|
|
|
|
func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
|
|
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(user.AutoGroups) == 0 {
|
|
return user, nil, nil
|
|
}
|
|
groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
groups := make([]*types.Group, 0, len(user.AutoGroups))
|
|
for _, id := range user.AutoGroups {
|
|
if g, ok := groupsMap[id]; ok && g != nil {
|
|
groups = append(groups, g)
|
|
}
|
|
}
|
|
return user, groups, nil
|
|
}
|