mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement package and cli tool for repairing oidc links (#26418)
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package authlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// OIDCLinkAnalysis contains the results of analyzing OIDC user links
|
||||
// grouped by their issuer prefix.
|
||||
type OIDCLinkAnalysis struct {
|
||||
Total int // Total OIDC user links
|
||||
Unlinked int // linked_id == ""
|
||||
CorrectIssuer int // linked_id starts with expectedIssuer||
|
||||
MismatchedCounts map[string]int // issuer -> count for non-matching issuers
|
||||
}
|
||||
|
||||
// MismatchedTotal returns the total number of links with a non-matching issuer.
|
||||
func (a OIDCLinkAnalysis) MismatchedTotal() int {
|
||||
total := 0
|
||||
for _, count := range a.MismatchedCounts {
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// AnalyzeOIDCLinks queries OIDC user links grouped by issuer prefix and
|
||||
// categorizes them relative to expectedIssuer.
|
||||
func AnalyzeOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (OIDCLinkAnalysis, error) {
|
||||
rows, err := db.CountOIDCLinkedIDsByIssuer(ctx)
|
||||
if err != nil {
|
||||
return OIDCLinkAnalysis{}, xerrors.Errorf("count OIDC linked IDs by issuer: %w", err)
|
||||
}
|
||||
|
||||
analysis := OIDCLinkAnalysis{
|
||||
MismatchedCounts: make(map[string]int),
|
||||
}
|
||||
for _, row := range rows {
|
||||
count := int(row.Count)
|
||||
analysis.Total += count
|
||||
switch {
|
||||
case row.IssuerPrefix == "":
|
||||
analysis.Unlinked += count
|
||||
case row.IssuerPrefix == expectedIssuer:
|
||||
analysis.CorrectIssuer += count
|
||||
default:
|
||||
analysis.MismatchedCounts[row.IssuerPrefix] += count
|
||||
}
|
||||
}
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
// ResetMismatchedOIDCLinks resets linked_id to empty for all OIDC links whose
|
||||
// issuer prefix does not match expectedIssuer. Returns the number of rows
|
||||
// affected.
|
||||
func ResetMismatchedOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (int64, error) {
|
||||
prefix := expectedIssuer + "||"
|
||||
count, err := db.UnlinkOIDCUsersByIssuerMismatch(ctx, prefix)
|
||||
if err != nil {
|
||||
return 0, xerrors.Errorf("unlink OIDC users by issuer mismatch: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ResolveIssuer uses OIDC discovery to fetch the canonical issuer string
|
||||
// from the provider's .well-known/openid-configuration endpoint.
|
||||
// This does not require OIDC client credentials.
|
||||
//
|
||||
// This works the same as `oidc.NewProvider`. The `oidc` package does not
|
||||
// expose a method to extract the Issuer. So we have to manually make the
|
||||
// http request.
|
||||
func ResolveIssuer(ctx context.Context, cli *http.Client, issuerURL string) (string, error) {
|
||||
wellKnownURL, err := url.JoinPath(issuerURL, "/.well-known/openid-configuration")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("resolve issuer URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("create discovery request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("fetch OIDC discovery document: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", xerrors.Errorf("OIDC discovery returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var discovery struct {
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return "", xerrors.Errorf("decode OIDC discovery document: %w", err)
|
||||
}
|
||||
if discovery.Issuer == "" {
|
||||
return "", xerrors.New("OIDC discovery document has empty issuer field")
|
||||
}
|
||||
return discovery.Issuer, nil
|
||||
}
|
||||
|
||||
// PrintAnalysis writes a human-readable summary of the OIDC link analysis.
|
||||
// Used for the cli command and debugging.
|
||||
func PrintAnalysis(w io.Writer, analysis OIDCLinkAnalysis, issuer string) {
|
||||
_, _ = fmt.Fprintf(w, "OIDC Link Analysis (issuer: %s)\n", issuer)
|
||||
_, _ = fmt.Fprintf(w, " Total OIDC users: %d\n", analysis.Total)
|
||||
_, _ = fmt.Fprintf(w, " Correctly linked: %d\n", analysis.CorrectIssuer)
|
||||
_, _ = fmt.Fprintf(w, " Unlinked (empty linked_id): %d\n", analysis.Unlinked)
|
||||
|
||||
mismatchedTotal := analysis.MismatchedTotal()
|
||||
_, _ = fmt.Fprintf(w, " Linked to other issuers: %d\n", mismatchedTotal)
|
||||
|
||||
if mismatchedTotal > 0 {
|
||||
// Sort issuer keys for deterministic output.
|
||||
issuers := make([]string, 0, len(analysis.MismatchedCounts))
|
||||
for issuer := range analysis.MismatchedCounts {
|
||||
issuers = append(issuers, issuer)
|
||||
}
|
||||
sort.Strings(issuers)
|
||||
for _, iss := range issuers {
|
||||
_, _ = fmt.Fprintf(w, " %s: %d\n", iss, analysis.MismatchedCounts[iss])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package authlink_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/authlink"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestAnalyzeOIDCLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("MixedIssuers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
// 3 users linked to the expected issuer.
|
||||
for i := 0; i < 3; i++ {
|
||||
user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: expectedIssuer + "||sub-" + user.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// 2 users linked to an old issuer.
|
||||
for i := 0; i < 2; i++ {
|
||||
user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "https://old-issuer.example.com||sub-" + user.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// 1 user linked to another old issuer.
|
||||
user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "https://staging.example.com||sub-" + user.ID.String(),
|
||||
})
|
||||
|
||||
// 1 unlinked user (empty linked_id).
|
||||
unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: unlinkedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "",
|
||||
})
|
||||
|
||||
analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 7, analysis.Total)
|
||||
require.Equal(t, 3, analysis.CorrectIssuer)
|
||||
require.Equal(t, 1, analysis.Unlinked)
|
||||
require.Equal(t, 3, analysis.MismatchedTotal())
|
||||
require.Equal(t, 2, analysis.MismatchedCounts["https://old-issuer.example.com"])
|
||||
require.Equal(t, 1, analysis.MismatchedCounts["https://staging.example.com"])
|
||||
})
|
||||
|
||||
t.Run("NoOIDCUsers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, "https://issuer.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, analysis.Total)
|
||||
require.Equal(t, 0, analysis.CorrectIssuer)
|
||||
require.Equal(t, 0, analysis.Unlinked)
|
||||
require.Equal(t, 0, analysis.MismatchedTotal())
|
||||
})
|
||||
|
||||
t.Run("AllCorrect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: expectedIssuer + "||sub-" + user.ID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, analysis.Total)
|
||||
require.Equal(t, 3, analysis.CorrectIssuer)
|
||||
require.Equal(t, 0, analysis.Unlinked)
|
||||
require.Equal(t, 0, analysis.MismatchedTotal())
|
||||
})
|
||||
|
||||
t.Run("DeletedUsersExcluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
// Active user with mismatched issuer.
|
||||
activeUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: activeUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "https://old-issuer.example.com||sub-active",
|
||||
})
|
||||
|
||||
// Create user and link first, then soft-delete the user.
|
||||
// The DB trigger prevents inserting links for already-deleted users.
|
||||
deletedUser := dbgen.User(t, db, database.User{
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: deletedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "https://old-issuer.example.com||sub-deleted",
|
||||
})
|
||||
require.NoError(t, db.UpdateUserDeletedByID(ctx, deletedUser.ID))
|
||||
|
||||
analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, analysis.Total)
|
||||
require.Equal(t, 1, analysis.MismatchedTotal())
|
||||
})
|
||||
|
||||
t.Run("NonOIDCLinksExcluded", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
// GitHub user link should not be counted.
|
||||
ghUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeGithub})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: ghUser.ID,
|
||||
LoginType: database.LoginTypeGithub,
|
||||
LinkedID: "github||12345",
|
||||
})
|
||||
|
||||
analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, analysis.Total)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetMismatchedOIDCLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ResetsOnlyMismatched", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
// Correctly linked user.
|
||||
correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
correctLink := dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: correctUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: expectedIssuer + "||sub-correct",
|
||||
})
|
||||
|
||||
// Mismatched user.
|
||||
mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: mismatchedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "https://old-issuer.example.com||sub-mismatched",
|
||||
})
|
||||
|
||||
// Unlinked user (empty linked_id).
|
||||
unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: unlinkedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: "",
|
||||
})
|
||||
|
||||
count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, count)
|
||||
|
||||
// Verify the correct link is unchanged.
|
||||
link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: correctUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, correctLink.LinkedID, link.LinkedID)
|
||||
|
||||
// Verify the mismatched link was reset.
|
||||
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: mismatchedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", link.LinkedID)
|
||||
|
||||
// Verify the unlinked user is still unlinked.
|
||||
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
|
||||
UserID: unlinkedUser.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", link.LinkedID)
|
||||
})
|
||||
|
||||
t.Run("NothingToReset", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
|
||||
user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
|
||||
dbgen.UserLink(t, db, database.UserLink{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
LinkedID: expectedIssuer + "||sub-correct",
|
||||
})
|
||||
|
||||
count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveIssuer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
const expectedIssuer = "https://accounts.google.com"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/.well-known/openid-configuration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": expectedIssuer,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
issuer, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedIssuer, issuer)
|
||||
})
|
||||
|
||||
t.Run("EmptyIssuer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": "",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "empty issuer")
|
||||
})
|
||||
|
||||
t.Run("HTTPError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "HTTP 500")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package authlink provides analysis and repair utilities for OIDC user link
|
||||
// records stored in the user_links table.
|
||||
//
|
||||
// When an OIDC provider is changed, the issuer (and possibly subject) in the
|
||||
// linked_id column changes. Because linked_id is composed as "issuer||subject",
|
||||
// existing users get locked out with "Account already linked" errors. The
|
||||
// functions in this package let an administrator inspect which links are
|
||||
// affected and reset the mismatched ones so users can re-authenticate under the
|
||||
// new provider.
|
||||
package authlink
|
||||
@@ -1928,6 +1928,14 @@ func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.Coun
|
||||
return q.db.CountInProgressPrebuilds(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
|
||||
// Requires the ability to read all user's personal data.
|
||||
if err := q.authorizeContext(ctx, policy.ActionReadPersonal, rbac.ResourceUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.CountOIDCLinkedIDsByIssuer(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil {
|
||||
return nil, err
|
||||
@@ -6927,6 +6935,13 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
|
||||
return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, rbac.ResourceUser); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix)
|
||||
}
|
||||
|
||||
func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
|
||||
chat, err := q.db.GetChatByID(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -4761,6 +4761,15 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
dbm.EXPECT().GetUserLinkByLinkedID(gomock.Any(), l.LinkedID).Return(l, nil).AnyTimes()
|
||||
check.Args(l.LinkedID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l)
|
||||
}))
|
||||
s.Run("CountOIDCLinkedIDsByIssuer", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().CountOIDCLinkedIDsByIssuer(gomock.Any()).Return([]database.CountOIDCLinkedIDsByIssuerRow{}, nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceUser, policy.ActionReadPersonal).Returns([]database.CountOIDCLinkedIDsByIssuerRow{})
|
||||
}))
|
||||
s.Run("UnlinkOIDCUsersByIssuerMismatch", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().UnlinkOIDCUsersByIssuerMismatch(gomock.Any(), "issuer||").Return(int64(0), nil).AnyTimes()
|
||||
check.Args("issuer||").Asserts(rbac.ResourceUser, policy.ActionUpdatePersonal).Returns(int64(0))
|
||||
}))
|
||||
|
||||
s.Run("GetUserLinkByUserIDLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
l := testutil.Fake(s.T(), faker, database.UserLink{})
|
||||
arg := database.GetUserLinkByUserIDLoginTypeParams{UserID: l.UserID, LoginType: l.LoginType}
|
||||
|
||||
+16
@@ -370,6 +370,14 @@ func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]data
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountOIDCLinkedIDsByIssuer(ctx)
|
||||
m.queryLatencies.WithLabelValues("CountOIDCLinkedIDsByIssuer").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountOIDCLinkedIDsByIssuer").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountPendingNonActivePrebuilds(ctx)
|
||||
@@ -4994,6 +5002,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix)
|
||||
m.queryLatencies.WithLabelValues("UnlinkOIDCUsersByIssuerMismatch").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnlinkOIDCUsersByIssuerMismatch").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UnpinChatByID(ctx, id)
|
||||
|
||||
Generated
+30
@@ -573,6 +573,21 @@ func (mr *MockStoreMockRecorder) CountInProgressPrebuilds(ctx any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountInProgressPrebuilds", reflect.TypeOf((*MockStore)(nil).CountInProgressPrebuilds), ctx)
|
||||
}
|
||||
|
||||
// CountOIDCLinkedIDsByIssuer mocks base method.
|
||||
func (m *MockStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountOIDCLinkedIDsByIssuer", ctx)
|
||||
ret0, _ := ret[0].([]database.CountOIDCLinkedIDsByIssuerRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountOIDCLinkedIDsByIssuer indicates an expected call of CountOIDCLinkedIDsByIssuer.
|
||||
func (mr *MockStoreMockRecorder) CountOIDCLinkedIDsByIssuer(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountOIDCLinkedIDsByIssuer", reflect.TypeOf((*MockStore)(nil).CountOIDCLinkedIDsByIssuer), ctx)
|
||||
}
|
||||
|
||||
// CountPendingNonActivePrebuilds mocks base method.
|
||||
func (m *MockStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -9417,6 +9432,21 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id)
|
||||
}
|
||||
|
||||
// UnlinkOIDCUsersByIssuerMismatch mocks base method.
|
||||
func (m *MockStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UnlinkOIDCUsersByIssuerMismatch", ctx, expectedPrefix)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UnlinkOIDCUsersByIssuerMismatch indicates an expected call of UnlinkOIDCUsersByIssuerMismatch.
|
||||
func (mr *MockStoreMockRecorder) UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlinkOIDCUsersByIssuerMismatch", reflect.TypeOf((*MockStore)(nil).UnlinkOIDCUsersByIssuerMismatch), ctx, expectedPrefix)
|
||||
}
|
||||
|
||||
// UnpinChatByID mocks base method.
|
||||
func (m *MockStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+9
@@ -107,6 +107,11 @@ type sqlcQuerier interface {
|
||||
// CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by preset ID and transition.
|
||||
// Prebuild considered in-progress if it's in the "pending", "starting", "stopping", or "deleting" state.
|
||||
CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error)
|
||||
// Groups OIDC user links by their issuer prefix (the part before "||" in
|
||||
// linked_id) and returns a count for each. Empty linked_ids are reported
|
||||
// with an empty issuer_prefix. Used for analysis before resetting
|
||||
// mismatched links.
|
||||
CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error)
|
||||
// CountPendingNonActivePrebuilds returns the number of pending prebuilds for non-active template versions
|
||||
CountPendingNonActivePrebuilds(ctx context.Context) ([]CountPendingNonActivePrebuildsRow, error)
|
||||
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
@@ -1299,6 +1304,10 @@ type sqlcQuerier interface {
|
||||
// This will always work regardless of the current state of the template version.
|
||||
UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error
|
||||
UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error
|
||||
// Resets linked_id to '' for OIDC links where the linked_id is non-empty
|
||||
// and does not begin with the expected issuer prefix. This allows users to
|
||||
// re-authenticate under a new OIDC provider.
|
||||
UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error)
|
||||
UnpinChatByID(ctx context.Context, id uuid.UUID) error
|
||||
UnsetDefaultChatModelConfigs(ctx context.Context) error
|
||||
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
|
||||
|
||||
Generated
+71
@@ -28866,6 +28866,55 @@ func (q *sqlQuerier) UpsertUserAIProviderKey(ctx context.Context, arg UpsertUser
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countOIDCLinkedIDsByIssuer = `-- name: CountOIDCLinkedIDsByIssuer :many
|
||||
SELECT
|
||||
(CASE
|
||||
WHEN user_links.linked_id = '' THEN ''
|
||||
ELSE split_part(user_links.linked_id, '||', 1)
|
||||
END)::text AS issuer_prefix,
|
||||
COUNT(*)::int AS count
|
||||
FROM
|
||||
user_links
|
||||
INNER JOIN
|
||||
users ON user_links.user_id = users.id
|
||||
WHERE
|
||||
user_links.login_type = 'oidc'
|
||||
AND users.deleted = false
|
||||
GROUP BY issuer_prefix
|
||||
`
|
||||
|
||||
type CountOIDCLinkedIDsByIssuerRow struct {
|
||||
IssuerPrefix string `db:"issuer_prefix" json:"issuer_prefix"`
|
||||
Count int32 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
// Groups OIDC user links by their issuer prefix (the part before "||" in
|
||||
// linked_id) and returns a count for each. Empty linked_ids are reported
|
||||
// with an empty issuer_prefix. Used for analysis before resetting
|
||||
// mismatched links.
|
||||
func (q *sqlQuerier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, countOIDCLinkedIDsByIssuer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CountOIDCLinkedIDsByIssuerRow
|
||||
for rows.Next() {
|
||||
var i CountOIDCLinkedIDsByIssuerRow
|
||||
if err := rows.Scan(&i.IssuerPrefix, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one
|
||||
SELECT
|
||||
user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims
|
||||
@@ -29124,6 +29173,28 @@ func (q *sqlQuerier) OIDCClaimFields(ctx context.Context, organizationID uuid.UU
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const unlinkOIDCUsersByIssuerMismatch = `-- name: UnlinkOIDCUsersByIssuerMismatch :execrows
|
||||
UPDATE user_links
|
||||
SET linked_id = ''
|
||||
FROM users
|
||||
WHERE user_links.user_id = users.id
|
||||
AND user_links.login_type = 'oidc'
|
||||
AND user_links.linked_id != ''
|
||||
AND NOT starts_with(user_links.linked_id, $1)
|
||||
AND users.deleted = false
|
||||
`
|
||||
|
||||
// Resets linked_id to ” for OIDC links where the linked_id is non-empty
|
||||
// and does not begin with the expected issuer prefix. This allows users to
|
||||
// re-authenticate under a new OIDC provider.
|
||||
func (q *sqlQuerier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, unlinkOIDCUsersByIssuerMismatch, expectedPrefix)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const updateUserLink = `-- name: UpdateUserLink :one
|
||||
UPDATE
|
||||
user_links
|
||||
|
||||
@@ -113,3 +113,36 @@ WHERE
|
||||
ELSE true
|
||||
END
|
||||
;
|
||||
|
||||
-- name: CountOIDCLinkedIDsByIssuer :many
|
||||
-- Groups OIDC user links by their issuer prefix (the part before "||" in
|
||||
-- linked_id) and returns a count for each. Empty linked_ids are reported
|
||||
-- with an empty issuer_prefix. Used for analysis before resetting
|
||||
-- mismatched links.
|
||||
SELECT
|
||||
(CASE
|
||||
WHEN user_links.linked_id = '' THEN ''
|
||||
ELSE split_part(user_links.linked_id, '||', 1)
|
||||
END)::text AS issuer_prefix,
|
||||
COUNT(*)::int AS count
|
||||
FROM
|
||||
user_links
|
||||
INNER JOIN
|
||||
users ON user_links.user_id = users.id
|
||||
WHERE
|
||||
user_links.login_type = 'oidc'
|
||||
AND users.deleted = false
|
||||
GROUP BY issuer_prefix;
|
||||
|
||||
-- name: UnlinkOIDCUsersByIssuerMismatch :execrows
|
||||
-- Resets linked_id to '' for OIDC links where the linked_id is non-empty
|
||||
-- and does not begin with the expected issuer prefix. This allows users to
|
||||
-- re-authenticate under a new OIDC provider.
|
||||
UPDATE user_links
|
||||
SET linked_id = ''
|
||||
FROM users
|
||||
WHERE user_links.user_id = users.id
|
||||
AND user_links.login_type = 'oidc'
|
||||
AND user_links.linked_id != ''
|
||||
AND NOT starts_with(user_links.linked_id, @expected_prefix)
|
||||
AND users.deleted = false;
|
||||
|
||||
Reference in New Issue
Block a user