Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
552 changes: 552 additions & 0 deletions docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ syncProfile:
2. `phone` 需通过手机号格式校验
3. 不符合语义的字段不应因字段名而强制落库

例如:`properties.oauth_Custom_email = 15986746954` 时,不应直接写入 `email`,而应优先识别为 `phone` 候选值。
例如:`properties.oauth_Custom_email = 15500000001` 时,不应直接写入 `email`,而应优先识别为 `phone` 候选值。

## 5.8 时序建议

Expand Down
4 changes: 4 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ func main() {
auth.GET("/callback", handlers.AuthCallback)
auth.GET("/login", handlers.Login)
auth.POST("/logout", handlers.Logout)
auth.GET("/bind/callback", handlers.AuthCallback)
}

// === Public read-only endpoints (OptionalAuth, no login required) ===
Expand Down Expand Up @@ -280,6 +281,9 @@ func main() {
authed.Use(middleware.RequireAuth(casdoorEndpoint, jwksProvider))
{
authed.GET("/auth/me", handlers.GetCurrentUser)
authed.GET("/auth/identities", handlers.ListBoundIdentities)
authed.POST("/auth/bind/start", handlers.StartBindAuth)
authed.POST("/auth/identities/:id/unbind", handlers.UnbindIdentity)

usage := authed.Group("/usage")
{
Expand Down
132 changes: 132 additions & 0 deletions server/cmd/migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"

"github.com/costrict/costrict-web/server/internal/config"
"github.com/costrict/costrict-web/server/internal/database"
Expand Down Expand Up @@ -176,13 +177,19 @@ func main() {
if err := ensureUserIdentityColumns(db); err != nil {
log.Fatalf("Failed to ensure user identity columns: %v", err)
}
if err := ensureUserAuthIdentitiesTable(db); err != nil {
log.Fatalf("Failed to ensure user auth identities table: %v", err)
}

if err := backfillCapabilityContentVersioning(db); err != nil {
log.Fatalf("Failed to backfill capability content versioning: %v", err)
}
if err := backfillUserExternalIdentities(db, false); err != nil {
log.Fatalf("Failed to backfill user external identities: %v", err)
}
if err := backfillUserAuthIdentities(db, false); err != nil {
log.Fatalf("Failed to backfill user auth identities: %v", err)
}

log.Println("All migrations completed successfully")
}
Expand Down Expand Up @@ -228,6 +235,39 @@ func ensureUserIdentityColumns(db *gorm.DB) error {
return nil
}

func ensureUserAuthIdentitiesTable(db *gorm.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS user_auth_identities (
id BIGSERIAL PRIMARY KEY,
user_subject_id text NOT NULL,
provider text NOT NULL,
issuer text,
external_key text NOT NULL,
external_subject text,
external_user_id text,
provider_user_id text,
display_name text,
email text,
phone text,
avatar_url text,
organization text,
is_primary boolean NOT NULL DEFAULT false,
last_login_at timestamptz,
created_at timestamptz,
updated_at timestamptz,
deleted_at timestamptz
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern $'type UserAuthIdentity struct {
  $$$
}'
rg -nP -C2 'UserAuthIdentity\b' --type=go -g '!**/*_test.go'

Repository: XDfield/costrict-web

Length of output: 12394


Unique index on external_key will conflict with soft-deleted rows.

The UserAuthIdentity model includes DeletedAt gorm.DeletedAt (line 595 in server/internal/models/models.go), confirming GORM soft-delete is in use. The UnbindIdentity function performs soft deletes, leaving rows physically present with deleted_at set. Subsequent attempts to bind the same external_key will fail with a unique-constraint violation since the index does not filter out soft-deleted rows.

Make the index partial to enforce uniqueness only on live rows:

Proposed fix
-		`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
+		`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key) WHERE deleted_at IS NULL`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key) WHERE deleted_at IS NULL`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/cmd/migrate/main.go` at line 260, The unique index on external_key
will conflict with soft-deleted UserAuthIdentity rows; update the index creation
SQL (the statement creating idx_user_auth_identities_external_key) to be a
partial index that only enforces uniqueness for live rows (i.e., add a WHERE
deleted_at IS NULL condition) so that soft-deleted records (gorm.DeletedAt)
don’t cause unique-constraint violations when UnbindIdentity performs soft
deletes.

`CREATE INDEX IF NOT EXISTS idx_user_auth_identities_user_subject_id ON user_auth_identities(user_subject_id)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("ensure user_auth_identities failed (%s): %w", stmt, err)
}
}
return nil
}

func backfillUserExternalIdentities(db *gorm.DB, dryRun bool) error {
hasPhone := db.Migrator().HasColumn(&models.User{}, "phone")
hasAuthProvider := db.Migrator().HasColumn(&models.User{}, "auth_provider")
Expand Down Expand Up @@ -315,6 +355,98 @@ func backfillUserExternalIdentities(db *gorm.DB, dryRun bool) error {
})
}

func backfillUserAuthIdentities(db *gorm.DB, dryRun bool) error {
type userRow struct {
SubjectID string
DisplayName *string
Email *string
Phone *string
AvatarURL *string
Organization *string
AuthProvider *string
ExternalKey *string
ProviderUserID *string
CasdoorUniversalID *string
CasdoorID *string
CasdoorSub *string
}
var users []userRow
if err := db.Table("users").Select("subject_id, display_name, email, phone, avatar_url, organization, auth_provider, external_key, provider_user_id, casdoor_universal_id, casdoor_id, casdoor_sub").Find(&users).Error; err != nil {
return fmt.Errorf("load users for auth identity backfill: %w", err)
}
created := 0
return db.Transaction(func(tx *gorm.DB) error {
for _, user := range users {
if strings.TrimSpace(user.SubjectID) == "" {
continue
}
externalKey := ""
if user.ExternalKey != nil {
externalKey = strings.TrimSpace(*user.ExternalKey)
}
if externalKey == "" {
if user.CasdoorUniversalID != nil && *user.CasdoorUniversalID != "" {
externalKey = "casdoor:" + *user.CasdoorUniversalID
} else if user.CasdoorSub != nil && *user.CasdoorSub != "" {
externalKey = "casdoor-sub:" + *user.CasdoorSub
} else if user.CasdoorID != nil && *user.CasdoorID != "" {
externalKey = "casdoor-id:" + *user.CasdoorID
}
}
if externalKey == "" {
continue
}
var count int64
if err := tx.Table("user_auth_identities").Where("external_key = ?", externalKey).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
provider := "casdoor"
if user.AuthProvider != nil && strings.TrimSpace(*user.AuthProvider) != "" {
provider = strings.ToLower(strings.TrimSpace(*user.AuthProvider))
}
created++
if dryRun {
continue
}
if err := tx.Table("user_auth_identities").Create(map[string]any{
"user_subject_id": user.SubjectID,
"provider": provider,
"external_key": externalKey,
"external_subject": coalesceStringPtr(user.CasdoorUniversalID, user.CasdoorSub),
"external_user_id": user.CasdoorID,
"provider_user_id": user.ProviderUserID,
"display_name": user.DisplayName,
"email": user.Email,
"phone": user.Phone,
"avatar_url": user.AvatarURL,
"organization": user.Organization,
"is_primary": true,
"created_at": time.Now(),
"updated_at": time.Now(),
}).Error; err != nil {
return fmt.Errorf("create backfilled auth identity for %s: %w", user.SubjectID, err)
}
}
log.Printf("user auth identity summary (dry-run=%v): created identities=%d", dryRun, created)
if dryRun {
return errDryRunRollback
}
return nil
})
}

func coalesceStringPtr(values ...*string) *string {
for _, value := range values {
if value != nil && strings.TrimSpace(*value) != "" {
return value
}
}
return nil
}

func isLikelyPhoneValue(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
Expand Down
25 changes: 23 additions & 2 deletions server/cmd/migrate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestBackfillUserExternalIdentities(t *testing.T) {
}

u1 := models.User{SubjectID: "u1", Username: "alice", CasdoorUniversalID: strPtr("uuid-1"), IsActive: true}
u2 := models.User{SubjectID: "u2", Username: "phone_15986746954", Email: strPtr("15986746954"), IsActive: true}
u2 := models.User{SubjectID: "u2", Username: "phone_15500000001", Email: strPtr("15500000001"), IsActive: true}
if err := db.Create(&u1).Error; err != nil {
t.Fatalf("create u1: %v", err)
}
Expand Down Expand Up @@ -120,11 +120,32 @@ func TestBackfillUserExternalIdentities(t *testing.T) {
if got2.AuthProvider == nil || *got2.AuthProvider != "phone" {
t.Fatalf("expected u2 auth_provider backfilled, got %+v", got2)
}
if got2.Phone == nil || *got2.Phone != "15986746954" {
if got2.Phone == nil || *got2.Phone != "15500000001" {
t.Fatalf("expected u2 phone backfilled from legacy email-like value, got %+v", got2)
}
}

func TestBackfillUserAuthIdentities(t *testing.T) {
db := newMigrateTestDB(t)
if err := db.AutoMigrate(&models.User{}, &models.UserAuthIdentity{}); err != nil {
t.Fatalf("migrate users/auth identities: %v", err)
}
u := models.User{SubjectID: "u1", Username: "alice", AuthProvider: strPtr("github"), ExternalKey: strPtr("casdoor:uuid-1"), ProviderUserID: strPtr("18633160"), CasdoorUniversalID: strPtr("uuid-1"), IsActive: true}
if err := db.Create(&u).Error; err != nil {
t.Fatalf("create user: %v", err)
}
if err := backfillUserAuthIdentities(db, false); err != nil {
t.Fatalf("backfill auth identities: %v", err)
}
var count int64
if err := db.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", "u1").Count(&count).Error; err != nil {
t.Fatalf("count identities: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 backfilled identity, got %d", count)
}
}


func TestBackfillCapabilityContentVersioning_SingleFile(t *testing.T) {
db := newMigrateTestDB(t)
Expand Down
Loading