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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -116,6 +117,58 @@ func (c *Connectors) LoadAllByOrganizationIDProtocolAndProvider(
|
|||||||
return nil
|
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(
|
func (c *Connectors) LoadByOrganizationIDWithoutDecryptedConnection(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
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