the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-operation-backend-r1/forgejo-operation-source.patch

1050 lines
38 KiB
Diff
Raw Normal View History

--- /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")
+ }
+ }
+}