--- /dev/null +++ b/models/auth/source_operation.go @@ -0,0 +1,430 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +// This owner-confined database primitive is NOT an authorization boundary. +// No CLI/web/API route exposes it. The admitted runtime must separately obtain +// fresh authenticated apply/inverse sessions and retain signed public evidence. +// Configurations and private journal rows must never leave database custody. + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "regexp" + "sort" + + "forgejo.org/models/db" + "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" + "xorm.io/xorm" +) + +var ( + ErrSourceOperationConflict = errors.New("source operation requires owner reconciliation") + ErrSourceOperationStorage = errors.New("source operation storage failure; reconcile before retry") + ErrSourceOperationUnsupported = errors.New("source operation requires standalone SQLite/PostgreSQL transaction with SQL logging disabled") + operationIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{7,127}$`) +) + +// Private source rows deliberately bypass Config decoding: decoding and +// reserializing can erase unknown fields, including a gate on an older image. +type sourceOperationRow struct { + ID int64 `xorm:"pk autoincr"` + Type Type + Name string + IsActive bool + IsSyncEnabled bool + Cfg string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp + UpdatedUnix timeutil.TimeStamp +} + +func (sourceOperationRow) TableName() string { return "login_source" } + +// The whole inventory, including private configs, stays in the owner's database. +// Neither journal payload nor its digest is a public result. +type sourceOperationJournal struct { + OperationID string `xorm:"pk VARCHAR(128)"` + CaseID string `xorm:"VARCHAR(128)"` + TargetName string + TargetID int64 + Phase string + BeforeState string `xorm:"TEXT"` + DesiredState string `xorm:"TEXT"` + PostState string `xorm:"TEXT"` +} + +func (sourceOperationJournal) TableName() string { return "login_source_operation" } +func init() { db.RegisterModel(new(sourceOperationJournal)) } + +// SourceOperationResult contains only public outcome metadata. An authenticated +// session/evidence producer must bind this result to its actual principal, +// admitted source revision, exact parameters and current operation session. +type SourceOperationResult struct { + OperationID string + Phase string + SourceID int64 +} + +// sourceOperationTx acquires a database-enforced lock before any source read. +// SQLite's first write serializes every database writer, including stock admin +// CRUD. PostgreSQL table locks exclude ordinary SQL source and user-link writes. +// Privileged DDL, process/provider refresh and release writers are outside this +// bounded primitive; they still require owner runtime/release exclusion. +func sourceOperationTx(ctx context.Context, f func(context.Context) error) error { + if db.InTransaction(ctx) || setting.Database.LogSQL || (!setting.Database.Type.IsSQLite3() && !setting.Database.Type.IsPostgreSQL()) { + return ErrSourceOperationUnsupported + } + err := db.WithTx(db.WithSensitiveSQL(ctx), func(tx context.Context) error { + session, ok := db.GetEngine(tx).(*xorm.Session) + if !ok || session.Engine().Logger().IsShowSQL() { + return ErrSourceOperationUnsupported + } + if setting.Database.Type.IsPostgreSQL() { + if _, err := db.GetEngine(tx).Exec(`LOCK TABLE login_source_operation, login_source, "user", external_login_user IN EXCLUSIVE MODE`); err != nil { + return err + } + } else { + // Even with no matching row, UPDATE starts SQLite's write transaction. + if _, err := db.GetEngine(tx).Exec(`UPDATE login_source_operation SET phase = phase WHERE operation_id = ''`); err != nil { + return err + } + } + return f(tx) + }) + if err == nil || errors.Is(err, ErrSourceOperationConflict) || errors.Is(err, ErrSourceOperationUnsupported) || IsErrSourceInUse(err) { + return err + } + return ErrSourceOperationStorage +} + +func sourceInventory(ctx context.Context) ([]sourceOperationRow, error) { + rows := make([]sourceOperationRow, 0) + err := db.GetEngine(ctx).OrderBy("id").Find(&rows) + return rows, err +} +func encodeSourceInventory(rows []sourceOperationRow) string { + b, _ := json.Marshal(rows) + return string(b) +} +func decodeSourceInventory(s string) ([]sourceOperationRow, error) { + var rows []sourceOperationRow + if err := json.Unmarshal([]byte(s), &rows); err != nil { + return nil, ErrSourceOperationConflict + } + return rows, nil +} +func sourceJournal(ctx context.Context, operationID string) (*sourceOperationJournal, error) { + row := new(sourceOperationJournal) + found, err := db.GetEngine(ctx).ID(operationID).Get(row) + if err != nil { + return nil, err + } + if !found { + return nil, ErrSourceOperationConflict + } + return row, nil +} +func journalResult(j *sourceOperationJournal) SourceOperationResult { + return SourceOperationResult{OperationID: j.OperationID, Phase: j.Phase, SourceID: j.TargetID} +} + +// PrepareSourceOperation durably captures exact source identity and complete +// original inventory before mutation. desired is private owner input, not an +// authorization token. targetID=0 requires authoritative name absence. Existing +// sources require both exact ID and name. Reuse of an operation ID is refused. +func PrepareSourceOperation(ctx context.Context, operationID, caseID string, targetID int64, desired *Source) (SourceOperationResult, error) { + var result SourceOperationResult + if !operationIDPattern.MatchString(operationID) || !operationIDPattern.MatchString(caseID) || desired == nil || desired.Type != OAuth2 || desired.Name == "" || desired.Cfg == nil || desired.IsSyncEnabled || desired.ID != targetID { + return result, ErrSourceOperationConflict + } + cfg, err := desired.Cfg.ToDB() + if err != nil || len(cfg) > 64*1024 || !json.Valid(cfg) { + return result, ErrSourceOperationConflict + } + candidate := sourceOperationRow{ID: targetID, Type: OAuth2, Name: desired.Name, IsActive: desired.IsActive, Cfg: string(cfg)} + candidateBytes, _ := json.Marshal(candidate) + err = sourceOperationTx(ctx, func(tx context.Context) error { + // Bound confidential duplicate custody. Refuse rather than erase journals + // with pending recovery or silently expire terminal private snapshots. + count, err := db.GetEngine(tx).Count(new(sourceOperationJournal)) + if err != nil { + return err + } + if count >= 256 { + return ErrSourceOperationConflict + } + active, err := db.GetEngine(tx).Where("phase IN (?, ?, ?)", "prepared", "applied", "unchanged").Count(new(sourceOperationJournal)) + if err != nil { + return err + } + if active >= 64 { + return ErrSourceOperationConflict + } + found, err := db.GetEngine(tx).ID(operationID).Exist(new(sourceOperationJournal)) + if err != nil { + return err + } + if found { + return ErrSourceOperationConflict + } + shared, err := db.GetEngine(tx).Where("target_name = ? AND phase IN (?, ?, ?)", desired.Name, "prepared", "applied", "unchanged").Exist(new(sourceOperationJournal)) + if err != nil { + return err + } + if shared { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if len(encodeSourceInventory(rows)) > 1024*1024 { + return ErrSourceOperationConflict + } + matched := false + for _, row := range rows { + if row.Name == desired.Name || row.ID == targetID { + if targetID == 0 || row.ID != targetID || row.Name != desired.Name || row.Type != OAuth2 { + return ErrSourceOperationConflict + } + matched = true + } + } + if targetID != 0 && !matched { + return ErrSourceOperationConflict + } + j := &sourceOperationJournal{OperationID: operationID, CaseID: caseID, TargetName: desired.Name, TargetID: targetID, Phase: "prepared", BeforeState: encodeSourceInventory(rows), DesiredState: string(candidateBytes)} + if _, err := db.GetEngine(tx).Insert(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// ApplySourceOperation atomically compares every original raw source row, +// mutates exactly the target and persists confirmed post-state. A lost commit +// acknowledgement is reconciled using the same operation ID, never a fresh ID. +// No source registration/network call occurs inside the database transaction. +func ApplySourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if j.Phase == "applied" || j.Phase == "unchanged" { + if encodeSourceInventory(rows) != j.PostState { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + } + if j.Phase != "prepared" || encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + var desired sourceOperationRow + if json.Unmarshal([]byte(j.DesiredState), &desired) != nil { + return ErrSourceOperationConflict + } + unchanged := false + for _, row := range rows { + if row.ID == j.TargetID { + desired.CreatedUnix = row.CreatedUnix + desired.UpdatedUnix = row.UpdatedUnix + unchanged = reflect.DeepEqual(row, desired) + } + } + if !unchanged { + desired.UpdatedUnix = timeutil.TimeStampNow() + if j.TargetID == 0 { + desired.CreatedUnix = desired.UpdatedUnix + if _, err := db.GetEngine(tx).NoAutoTime().Insert(&desired); err != nil { + return err + } + j.TargetID = desired.ID + } else { + n, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(&desired) + if err != nil { + return err + } + if n != 1 { + return ErrSourceOperationConflict + } + } + } + post, err := sourceInventory(tx) + if err != nil { + return err + } + expected := append([]sourceOperationRow{}, rows...) + if !unchanged { + replaced := false + for i := range expected { + if expected[i].ID == desired.ID { + expected[i] = desired + replaced = true + } + } + if !replaced { + expected = append(expected, desired) + } + sort.Slice(expected, func(i, k int) bool { return expected[i].ID < expected[k].ID }) + } + if !reflect.DeepEqual(post, expected) { + return ErrSourceOperationConflict + } + j.PostState = encodeSourceInventory(post) + j.Phase = "applied" + if unchanged { + j.Phase = "unchanged" + } + if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// InverseSourceOperation requires fresh external inverse authority on EACH call. +// It restores a preexisting source in place; only confirmed case creation can +// delete. Any linked local/external account refuses deletion without alteration. +func InverseSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + if j.Phase == "reverted" || j.Phase == "cancelled" { + if encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + } + if j.Phase == "prepared" { + if encodeSourceInventory(rows) != j.BeforeState { + return ErrSourceOperationConflict + } + j.Phase = "cancelled" + } else { + if (j.Phase != "applied" && j.Phase != "unchanged") || encodeSourceInventory(rows) != j.PostState { + return ErrSourceOperationConflict + } + before, err := decodeSourceInventory(j.BeforeState) + if err != nil { + return err + } + var original *sourceOperationRow + for i := range before { + if before[i].ID == j.TargetID { + original = &before[i] + } + } + if original != nil { + if _, err := db.GetEngine(tx).ID(j.TargetID).NoAutoTime().AllCols().Update(original); err != nil { + return err + } + } else { + for _, table := range []struct{ name, column string }{{"user", "login_source"}, {"external_login_user", "login_source_id"}} { + count, err := db.GetEngine(tx).Table(table.name).Where(table.column+" = ?", j.TargetID).Count() + if err != nil { + return err + } + if count != 0 { + return ErrSourceInUse{ID: j.TargetID} + } + } + n, err := db.GetEngine(tx).ID(j.TargetID).Delete(new(sourceOperationRow)) + if err != nil { + return err + } + if n != 1 { + return ErrSourceOperationConflict + } + } + restored, err := sourceInventory(tx) + if err != nil { + return err + } + if encodeSourceInventory(restored) != j.BeforeState { + return ErrSourceOperationConflict + } + j.Phase = "reverted" + } + if _, err := db.GetEngine(tx).ID(j.OperationID).AllCols().Update(j); err != nil { + return err + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} + +// ReconcileSourceOperation only checks committed journal/current equality. It +// never infers that a prepared intent ran and never retries the mutation. The +// separately authenticated runtime must call this after an ambiguous result. +func ReconcileSourceOperation(ctx context.Context, operationID, caseID string) (SourceOperationResult, error) { + var result SourceOperationResult + err := sourceOperationTx(ctx, func(tx context.Context) error { + j, err := sourceJournal(tx, operationID) + if err != nil { + return err + } + if j.CaseID != caseID { + return ErrSourceOperationConflict + } + rows, err := sourceInventory(tx) + if err != nil { + return err + } + expected := j.BeforeState + switch j.Phase { + case "prepared", "reverted", "cancelled": + case "applied", "unchanged": + expected = j.PostState + default: + return ErrSourceOperationConflict + } + if encodeSourceInventory(rows) != expected { + return ErrSourceOperationConflict + } + result = journalResult(j) + return nil + }) + if err != nil { + return SourceOperationResult{}, err + } + return result, nil +} --- /dev/null +++ b/models/auth/source_operation_test.go @@ -0,0 +1,235 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + auth "forgejo.org/models/auth" + "forgejo.org/models/db" + "forgejo.org/models/unittest" + "forgejo.org/modules/setting" + oauth2 "forgejo.org/services/auth/source/oauth2" + + "github.com/stretchr/testify/require" +) + +func operationDB(t *testing.T) context.Context { + t.Helper() + require.NoError(t, unittest.PrepareTestDatabase()) + _, err := db.GetEngine(db.DefaultContext).Exec("DELETE FROM login_source_operation") + require.NoError(t, err) + auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) + return db.DefaultContext +} +func operationSource() *auth.Source { + return &auth.Source{Type: auth.OAuth2, Name: "operation-test", IsActive: false, Cfg: &oauth2.Source{Provider: "openidConnect", ClientID: "synthetic-client", ClientSecret: "synthetic-private-original", RequiredClaimName: "groups", RequiredClaimValues: []string{"g1", "g2", "g3"}, Scopes: []string{"openid", "profile", "groups"}}} +} +func operationRows(t *testing.T, ctx context.Context) string { + t.Helper() + rows, err := db.GetEngine(ctx).Query("SELECT * FROM login_source ORDER BY id") + require.NoError(t, err) + b, err := json.Marshal(rows) + require.NoError(t, err) + return string(b) +} +func TestSourceOperationCreateAndInverse(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + r, err := auth.PrepareSourceOperation(ctx, "operation-create", "case-create", 0, operationSource()) + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) + r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, "applied", r.Phase) + require.Positive(t, r.SourceID) + id := r.SourceID + post := operationRows(t, ctx) + r, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, id, r.SourceID) + require.Equal(t, post, operationRows(t, ctx)) + _, err = auth.InverseSourceOperation(ctx, "operation-create", "wrong-case") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + r, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + require.Equal(t, "reverted", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) + _, err = auth.InverseSourceOperation(ctx, "operation-create", "case-create") + require.NoError(t, err) + _, err = auth.ApplySourceOperation(ctx, "operation-create", "case-create") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) +} +func TestSourceOperationRestoresExactRawPreexisting(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + // The old/unknown field must survive restoration byte for byte, including the + // private synthetic secret. A typed OAuth2 decoder would silently lose it. + raw := `{"Provider":"openidConnect","ClientID":"synthetic-client","ClientSecret":"synthetic-private-original","FutureGuard":{"x":true}}` + _, err := db.GetEngine(ctx).Exec("UPDATE login_source SET cfg = ? WHERE id = ?", raw, s.ID) + require.NoError(t, err) + before := operationRows(t, ctx) + s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-new" + _, err = auth.PrepareSourceOperation(ctx, "operation-restore", "case-restore", s.ID, s) + require.NoError(t, err) + _, err = auth.ApplySourceOperation(ctx, "operation-restore", "case-restore") + require.NoError(t, err) + _, err = auth.InverseSourceOperation(ctx, "operation-restore", "case-restore") + require.NoError(t, err) + require.Equal(t, before, operationRows(t, ctx)) + source, err := auth.GetSourceByID(ctx, s.ID) + require.NoError(t, err) + require.Equal(t, s.ID, source.ID) +} +func TestSourceOperationUnchangedNeverDeletesPreexisting(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-unchanged", "case-unchanged", s.ID, s) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-unchanged", "case-unchanged") + require.NoError(t, err) + require.Equal(t, "unchanged", r.Phase) + _, err = auth.InverseSourceOperation(ctx, "operation-unchanged", "case-unchanged") + require.NoError(t, err) + require.Equal(t, before, operationRows(t, ctx)) +} +func TestSourceOperationPrivateDriftRefuses(t *testing.T) { + for _, stage := range []string{"before-apply", "before-inverse"} { + t.Run(stage, func(t *testing.T) { + ctx := operationDB(t) + s := operationSource() + require.NoError(t, auth.CreateSource(ctx, s)) + _, err := auth.PrepareSourceOperation(ctx, "operation-private-drift", "case-private-drift", s.ID, s) + require.NoError(t, err) + if stage == "before-inverse" { + _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") + require.NoError(t, err) + } + s.Cfg.(*oauth2.Source).ClientSecret = "synthetic-private-drift" + require.NoError(t, auth.UpdateSource(ctx, s)) + drift := operationRows(t, ctx) + if stage == "before-apply" { + _, err = auth.ApplySourceOperation(ctx, "operation-private-drift", "case-private-drift") + } else { + _, err = auth.InverseSourceOperation(ctx, "operation-private-drift", "case-private-drift") + } + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.NotContains(t, err.Error(), "synthetic-private") + require.Equal(t, drift, operationRows(t, ctx)) + }) + } +} +func TestSourceOperationStockCRUDDriftRefuses(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-other-writer", "case-other-writer", 0, operationSource()) + require.NoError(t, err) + other := operationSource() + other.Name = "unrelated-source" + require.NoError(t, auth.CreateSource(ctx, other)) + drift := operationRows(t, ctx) + _, err = auth.ApplySourceOperation(ctx, "operation-other-writer", "case-other-writer") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.Equal(t, drift, operationRows(t, ctx)) +} +func TestSourceOperationLinkedSourceRefusesDeletion(t *testing.T) { + for _, kind := range []string{"user", "external"} { + t.Run(kind, func(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-linked", "case-linked", 0, operationSource()) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-linked", "case-linked") + require.NoError(t, err) + if kind == "user" { + _, err = db.GetEngine(ctx).Exec("UPDATE `user` SET login_source = ? WHERE id = 1", r.SourceID) + } else { + _, err = db.GetEngine(ctx).Exec("INSERT INTO external_login_user (external_id, user_id, login_source_id) VALUES (?, ?, ?)", "synthetic-external", 1, r.SourceID) + } + require.NoError(t, err) + before := operationRows(t, ctx) + _, err = auth.InverseSourceOperation(ctx, "operation-linked", "case-linked") + require.True(t, auth.IsErrSourceInUse(err)) + require.Equal(t, before, operationRows(t, ctx)) + if kind == "user" { + rows, e := db.GetEngine(ctx).Query("SELECT login_source FROM `user` WHERE id = 1") + require.NoError(t, e) + require.NotEqual(t, []byte("0"), rows[0]["login_source"]) + } else { + n, e := db.GetEngine(ctx).Table("external_login_user").Count() + require.NoError(t, e) + require.EqualValues(t, 1, n) + } + }) + } +} +func TestSourceOperationPreparedCancellationAndIDReuse(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) + require.NoError(t, err) + _, err = auth.PrepareSourceOperation(ctx, "operation-cancelled", "case-cancelled", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + r, err := auth.InverseSourceOperation(ctx, "operation-cancelled", "case-cancelled") + require.NoError(t, err) + require.Equal(t, "cancelled", r.Phase) + require.Equal(t, before, operationRows(t, ctx)) +} +func TestSourceOperationRejectsUnsafeRuntime(t *testing.T) { + ctx := operationDB(t) + setting.Database.LogSQL = true + t.Cleanup(func() { setting.Database.LogSQL = false }) + _, err := auth.PrepareSourceOperation(ctx, "operation-no-logging", "case-no-logging", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) + setting.Database.LogSQL = false + err = db.WithTx(ctx, func(tx context.Context) error { + _, err := auth.PrepareSourceOperation(tx, "operation-nested", "case-nested", 0, operationSource()) + return err + }) + require.ErrorIs(t, err, auth.ErrSourceOperationUnsupported) +} +func TestSourceOperationRefusesSharedManagedSource(t *testing.T) { + ctx := operationDB(t) + _, err := auth.PrepareSourceOperation(ctx, "operation-first-case", "case-first-case", 0, operationSource()) + require.NoError(t, err) + r, err := auth.ApplySourceOperation(ctx, "operation-first-case", "case-first-case") + require.NoError(t, err) + shared := operationSource() + shared.ID = r.SourceID + _, err = auth.PrepareSourceOperation(ctx, "operation-second-case", "case-second-case", r.SourceID, shared) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) +} +func TestSourceOperationBoundsPrivateJournalCustody(t *testing.T) { + ctx := operationDB(t) + // A full unresolved-journal budget refuses new preparation, without deleting + // any intent or replacing private recovery data with an expiry assumption. + for i := 0; i < 64; i++ { + _, err := db.GetEngine(ctx).Exec("INSERT INTO login_source_operation (operation_id, case_id, target_name, phase) VALUES (?, ?, ?, ?)", fmt.Sprintf("bounded-operation-%03d", i), "bounded-case", fmt.Sprintf("bounded-target-%03d", i), "prepared") + require.NoError(t, err) + } + _, err := auth.PrepareSourceOperation(ctx, "operation-budget-full", "case-budget-full", 0, operationSource()) + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + n, err := db.GetEngine(ctx).Table("login_source_operation").Count() + require.NoError(t, err) + require.EqualValues(t, 64, n) +} +func TestSourceOperationRequiresExactPostState(t *testing.T) { + ctx := operationDB(t) + before := operationRows(t, ctx) + _, err := auth.PrepareSourceOperation(ctx, "operation-post-exact", "case-post-exact", 0, operationSource()) + require.NoError(t, err) + _, err = db.GetEngine(ctx).Exec("CREATE TRIGGER source_operation_test_drift AFTER INSERT ON login_source BEGIN UPDATE login_source SET name = 'unexpected-rewrite' WHERE id = NEW.id; END") + require.NoError(t, err) + defer db.GetEngine(ctx).Exec("DROP TRIGGER source_operation_test_drift") + _, err = auth.ApplySourceOperation(ctx, "operation-post-exact", "case-post-exact") + require.ErrorIs(t, err, auth.ErrSourceOperationConflict) + require.Equal(t, before, operationRows(t, ctx)) + r, err := auth.ReconcileSourceOperation(ctx, "operation-post-exact", "case-post-exact") + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) +} --- /dev/null +++ b/models/auth/source_operation_internal_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth + +import ( + "context" + "testing" + "time" + + "forgejo.org/models/db" + "xorm.io/xorm" +) + +func TestSourceOperationDatabaseWriterExclusion(t *testing.T) { + // Use DEFERRED transaction mode in the managed engine as well, so this + // test detects removal of the primitive's explicit first write. + previousContext := db.DefaultContext + previousEngine, ok := db.GetEngine(previousContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual fixture engine") + } + managed, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") + if err != nil { + t.Fatal(err) + } + db.SetDefaultEngine(context.Background(), managed) + defer func() { db.SetDefaultEngine(previousContext, previousEngine); managed.Close() }() + // A second actual connection executes uncooperative raw SQL, so this checks + // database exclusion rather than an advisory-lock convention. + raw, err := xorm.NewEngine("sqlite3", "file::memory:?cache=shared&_txlock=deferred") + if err != nil { + t.Fatal(err) + } + defer raw.Close() + locked := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- sourceOperationTx(db.DefaultContext, func(ctx context.Context) error { close(locked); <-release; return nil }) + }() + select { + case <-locked: + case err := <-done: + t.Fatalf("lock failed: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("lock timed out") + } + writeDone := make(chan error, 1) + go func() { _, err := raw.Exec("UPDATE login_source SET name = name WHERE id = -123"); writeDone <- err }() + select { + case err := <-writeDone: + close(release) + <-done + t.Fatalf("uncooperative write escaped exclusion: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("transaction did not finish") + } + select { + case err := <-writeDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("writer did not resume") + } +} +func TestSourceOperationRefusesActualXORMArgumentLogging(t *testing.T) { + engine, ok := db.GetEngine(db.DefaultContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual engine") + } + engine.ShowSQL(true) + defer engine.ShowSQL(false) + called := false + err := sourceOperationTx(db.DefaultContext, func(context.Context) error { called = true; return nil }) + if err != ErrSourceOperationUnsupported || called { + t.Fatal("actual SQL argument logger did not fail closed") + } +} --- /dev/null +++ b/models/auth/source_operation_process_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package auth_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + auth "forgejo.org/models/auth" + "forgejo.org/models/db" + oauth2 "forgejo.org/services/auth/source/oauth2" + + "github.com/stretchr/testify/require" + "xorm.io/xorm" + "xorm.io/xorm/contexts" + "xorm.io/xorm/names" +) + +type operationCrashAfterSourceInsert struct{} + +func (operationCrashAfterSourceInsert) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { + return c.Ctx, nil +} +func (operationCrashAfterSourceInsert) AfterProcess(c *contexts.ContextHook) error { + if c.Err == nil && strings.Contains(c.SQL, "INSERT INTO") && strings.Contains(c.SQL, "login_source") && !strings.Contains(c.SQL, "login_source_operation") { + // Process death after the real INSERT, before actual ApplySourceOperation's + // post-read/journal update/commit. No artificial operation is substituted. + os.Exit(73) + } + return nil +} +func TestSourceOperationProcessRecovery(t *testing.T) { + if step := os.Getenv("SOURCE_OPERATION_CHILD_STEP"); step != "" { + e, err := xorm.NewEngine("sqlite3", os.Getenv("SOURCE_OPERATION_CHILD_DB")+"?_txlock=deferred") + require.NoError(t, err) + e.SetMapper(names.GonicMapper{}) + db.SetDefaultEngine(context.Background(), e) + require.NoError(t, db.SyncAllTables()) + auth.RegisterTypeConfig(auth.OAuth2, new(oauth2.Source)) + ctx := db.DefaultContext + switch step { + case "prepare": + _, err = auth.PrepareSourceOperation(ctx, "operation-process-recovery", "case-process-recovery", 0, operationSource()) + require.NoError(t, err) + case "crash": + e.AddHook(operationCrashAfterSourceInsert{}) + _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + t.Fatal("real insert did not reach crash hook") + case "recover-prepared": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "prepared", r.Phase) + n, err := db.GetEngine(ctx).Table("login_source").Count() + require.NoError(t, err) + require.Zero(t, n) + _, err = auth.ApplySourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + case "recover-applied": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "applied", r.Phase) + _, err = auth.InverseSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + case "recover-inverse": + r, err := auth.ReconcileSourceOperation(ctx, "operation-process-recovery", "case-process-recovery") + require.NoError(t, err) + require.Equal(t, "reverted", r.Phase) + n, err := db.GetEngine(ctx).Table("login_source").Count() + require.NoError(t, err) + require.Zero(t, n) + default: + t.Fatal("unknown synthetic child step") + } + require.NoError(t, e.Close()) + return + } + exe, err := os.Executable() + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "owner-private.sqlite") + for _, step := range []string{"prepare", "crash", "recover-prepared", "recover-applied", "recover-inverse"} { + child := exec.Command(exe, "-test.run=^TestSourceOperationProcessRecovery$", "-test.v") + child.Env = append(os.Environ(), "SOURCE_OPERATION_CHILD_STEP="+step, "SOURCE_OPERATION_CHILD_DB="+path) + output, err := child.CombinedOutput() + if step == "crash" { + exit, ok := err.(*exec.ExitError) + require.True(t, ok, string(output)) + require.Equal(t, 73, exit.ExitCode(), string(output)) + } else { + require.NoError(t, err, string(output)) + } + } +} --- a/models/db/engine.go +++ b/models/db/engine.go @@ -330,8 +330,12 @@ func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) { ctx, task := trace.NewTask(c.Ctx, "sql") ctx = context.WithValue(ctx, sqlTask{}, task) - trace.Log(ctx, "query", c.SQL) - trace.Logf(ctx, "args", "%v", c.Args) + if isSensitiveSQL(c.Ctx) { + trace.Log(ctx, "query", "[owner-confined query]") + } else { + trace.Log(ctx, "query", c.SQL) + trace.Logf(ctx, "args", "%v", c.Args) + } return ctx, nil } @@ -353,7 +357,11 @@ func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error { if c.ExecuteTime >= h.Treshold { - h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) + if isSensitiveSQL(c.Ctx) { + h.Logger.Log(8, log.WARN, "[Slow SQL Query] [owner-confined query] - %v", c.ExecuteTime) + } else { + h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime) + } } return nil } @@ -370,7 +378,11 @@ func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error { if c.Err != nil && !errors.Is(c.Err, context.Canceled) { - h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) - } - return nil -} + if isSensitiveSQL(c.Ctx) { + h.Logger.Log(8, log.ERROR, "[Error SQL Query] [owner-confined query]") + } else { + h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err) + } + } + return nil +} --- a/models/db/context.go +++ b/models/db/context.go @@ -72,6 +72,12 @@ // GetEngine will get a db Engine from this context or return an Engine restricted to this context func GetEngine(ctx context.Context) Engine { if e := getEngine(ctx); e != nil { + // A private-query marker must reach XORM's real query hooks, including + // inside an existing engine/transaction context. Wrapping the Go + // context alone does not change XORM's stored session context. + if isSensitiveSQL(ctx) { + return e.Context(ctx) + } return e } return x.Context(ctx) --- /dev/null +++ b/models/db/sensitive_sql.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package db + +import "context" + +type sensitiveSQLKey struct{} + +// WithSensitiveSQL suppresses argument, statement and driver-error disclosure +// in the application's query hooks. The caller must separately refuse raw XORM +// SQL logging; custom driver/server auditing remains an owner custody concern. +func WithSensitiveSQL(ctx context.Context) context.Context { + return context.WithValue(ctx, sensitiveSQLKey{}, true) +} +func isSensitiveSQL(ctx context.Context) bool { + sensitive, _ := ctx.Value(sensitiveSQLKey{}).(bool) + return sensitive +} --- /dev/null +++ b/models/db/sensitive_sql_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package db + +import ( + "bytes" + "context" + "errors" + "fmt" + "runtime/trace" + "strings" + "testing" + "time" + + "forgejo.org/modules/log" + "xorm.io/xorm" + "xorm.io/xorm/contexts" +) + +type sensitiveSQLLog struct { + log.Logger + entries []string +} + +func (l *sensitiveSQLLog) Log(_ int, _ log.Level, f string, v ...any) { + l.entries = append(l.entries, fmt.Sprintf(f, v...)) +} +func TestSensitiveSQLHooks(t *testing.T) { + const secret = "synthetic-query-private-sentinel" + for _, sensitive := range []bool{false, true} { + ctx := context.Background() + if sensitive { + ctx = WithSensitiveSQL(ctx) + } + h := &contexts.ContextHook{Ctx: ctx, SQL: "SELECT '" + secret + "'", Args: []any{secret}, Err: errors.New(secret), ExecuteTime: time.Second} + logger := new(sensitiveSQLLog) + if err := (&SlowQueryHook{Logger: logger}).AfterProcess(h); err != nil { + t.Fatal(err) + } + if err := (&ErrorQueryHook{Logger: logger}).AfterProcess(h); err != nil { + t.Fatal(err) + } + found := strings.Contains(strings.Join(logger.entries, "\n"), secret) + if found == sensitive { + t.Fatal("query hook disclosure/redaction mismatch") + } + var out bytes.Buffer + if err := trace.Start(&out); err != nil { + t.Fatal(err) + } + next, err := (TracingHook{}).BeforeProcess(h) + if err != nil { + trace.Stop() + t.Fatal(err) + } + h.Ctx = next + err = (TracingHook{}).AfterProcess(h) + trace.Stop() + if err != nil { + t.Fatal(err) + } + if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { + t.Fatal("trace disclosure/redaction mismatch") + } + } +} + +func TestSensitiveSQLActualContextPropagation(t *testing.T) { + engine, ok := GetEngine(DefaultContext).(*xorm.Engine) + if !ok { + t.Fatal("missing actual fixture engine") + } + logger := new(sensitiveSQLLog) + engine.AddHook(&SlowQueryHook{Logger: logger}) + engine.AddHook(&ErrorQueryHook{Logger: logger}) + engine.AddHook(TracingHook{}) + const secret = "synthetic-actual-xorm-private-sentinel" + for _, sensitive := range []bool{false, true} { + logger.entries = nil + ctx := context.Context(DefaultContext) + if sensitive { + ctx = WithSensitiveSQL(ctx) + } + var out bytes.Buffer + if err := trace.Start(&out); err != nil { + t.Fatal(err) + } + _, err := GetEngine(ctx).Exec("SELECT missing_synthetic_function(?)", secret) + trace.Stop() + if err == nil { + t.Fatal("expected actual SQLite function refusal") + } + if len(logger.entries) < 2 { + t.Fatal("actual query hooks did not execute") + } + if strings.Contains(strings.Join(logger.entries, "\n"), secret) == sensitive { + t.Fatal("actual XORM query context did not preserve redaction") + } + if bytes.Contains(out.Bytes(), []byte(secret)) == sensitive { + t.Fatal("actual XORM trace context did not preserve redaction") + } + } +}