feat(coredata): add widest-scope connector loader
LoadOneByOrganizationIDAndProvider returns the effective OAuth2 connector for an (organization, provider) pair. When legacy rows leave multiple candidates behind (pre-auto-reconnect installs), it picks the row with the widest stored scope set and tiebreaks by most recent updated_at. This is correct for Google Workspace where the SCIM bridge's 4-scope row must win over the access review's 2-scope row so the caller always sees a token that can run SCIM operations. The selector reads the decrypted scope set through Connection.Scopes, so it is robust against token-refresh updated_at churn that would mislead a pure SQL ORDER BY. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -116,6 +117,58 @@ func (c *Connectors) LoadAllByOrganizationIDProtocolAndProvider(
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadOneByOrganizationIDAndProvider loads the effective OAuth2
|
||||
// connector for an (organization, provider) pair, picking the row with
|
||||
// the widest stored scope set. Ties are broken by most recent
|
||||
// updated_at. Returns ErrResourceNotFound if no OAuth2 row exists.
|
||||
func (c *Connector) LoadOneByOrganizationIDAndProvider(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
organizationID gid.GID,
|
||||
provider ConnectorProvider,
|
||||
) error {
|
||||
var connectors Connectors
|
||||
if err := connectors.LoadAllByOrganizationIDProtocolAndProvider(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
organizationID,
|
||||
ConnectorProtocolOAuth2,
|
||||
provider,
|
||||
encryptionKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load connectors: %w", err)
|
||||
}
|
||||
|
||||
if len(connectors) == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
// Widest-scope-wins, tiebreak by most recent updated_at.
|
||||
sort.SliceStable(connectors, func(i, j int) bool {
|
||||
ci, cj := connectorScopeCount(connectors[i]), connectorScopeCount(connectors[j])
|
||||
if ci != cj {
|
||||
return ci > cj
|
||||
}
|
||||
return connectors[i].UpdatedAt.After(connectors[j].UpdatedAt)
|
||||
})
|
||||
|
||||
*c = *connectors[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectorScopeCount returns the number of scopes granted on a
|
||||
// decrypted connector's connection. Returns 0 if the connection is nil.
|
||||
// Used by the widest-scope selector.
|
||||
func connectorScopeCount(c *Connector) int {
|
||||
if c == nil || c.Connection == nil {
|
||||
return 0
|
||||
}
|
||||
return len(c.Connection.Scopes())
|
||||
}
|
||||
|
||||
func (c *Connectors) LoadByOrganizationIDWithoutDecryptedConnection(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
94
pkg/coredata/connector_test.go
Normal file
94
pkg/coredata/connector_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
)
|
||||
|
||||
func TestConnectorScopeCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in *Connector
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "nil connector",
|
||||
in: nil,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "nil connection",
|
||||
in: &Connector{},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "oauth2 empty scope",
|
||||
in: &Connector{
|
||||
Connection: &connector.OAuth2Connection{Scope: ""},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "oauth2 single scope",
|
||||
in: &Connector{
|
||||
Connection: &connector.OAuth2Connection{Scope: "read:user"},
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "oauth2 multiple scopes",
|
||||
in: &Connector{
|
||||
Connection: &connector.OAuth2Connection{Scope: "read:user write:user admin:org"},
|
||||
},
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "oauth2 github comma scopes",
|
||||
in: &Connector{
|
||||
Connection: &connector.OAuth2Connection{Scope: "repo,gist,user"},
|
||||
},
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "slack multi scope",
|
||||
in: &Connector{
|
||||
Connection: &connector.SlackConnection{
|
||||
OAuth2Connection: connector.OAuth2Connection{Scope: "chat:write channels:join incoming-webhook"},
|
||||
},
|
||||
},
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "unknown connection type",
|
||||
in: &Connector{
|
||||
Connection: &connector.APIKeyConnection{},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, c.want, connectorScopeCount(c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user