Repository: casbin/casbin

Issue:
[Feature] Add support for transactions for enforcers
**Want to prioritize this issue? Try:**

[![issuehunt-to-marktext](https://github.com/BoostIO/issuehunt-materials/raw/master/v1/issuehunt-button-v1.svg)](https://issuehunt.io/r/casbin/casbin)

------

**Is your feature request related to a problem? Please describe.**

Currently casbin does not support transactions natively and delegates this to adapters (https://github.com/casbin/casbin/issues/1205), but this in practice leads to many problems:

* gorm adapter (as an example, but this is fundamental issue) [has to introduce a global mutex to serialize all transactions](https://github.com/casbin/gorm-adapter/blob/master/adapter.go#L743-L771) because it has to `SetAdapter` on `enforcer` to pass transaction-bound adapter to it, because it is changing global enforcer state, it has to use a mutex (correctly) which makes effectively only one transaction/operation at a time possible
* even when adapter supports transactions, it still can lead to desynchronization between database and casbin state if somebody has a API-level transactions and uses nested transactions: [addPolicyWithoutNotify](https://github.com/casbin/casbin/blob/master/internal_api.go#L38-L69) calls into adapter, fine, that call succeeds, `addPolicyWithoutNotify` calls `e.model.AddPolicy`, fine, but now imagine that external transaction rolls back because something else in API handling aborts - now casbin state contains added policy, while adapter's database state reverts - desynchronization

**Describe the solution you'd like**

I think there is no other way than for casbin to provide some API to handle transactions on its level - so start a transaction and then all `AddPolicy` calls add policies to some temporary buffer and then there should be an easy way to rollback or commit them at the end.

**Describe alternatives you've considered**

One would have to manually track what has been added and what has been reverted. Or simply skip casbin's `AddPolicy` and add policies directly to the database and then load policies from the database once everything commits. But that introduces potential period when database and casbin's state are not consistent. I find casbin's big advantage over other systems that I can embed it into my own program and transactions and have database state synced - with external services for authorizations, like SpiceDB and openfga, this state syncing issue is what one had to deal with, but with casbin this could be simplified and embedded in overall transactions for the database one is using.


Discussion:
@mitar: This was really quick. Amazing. Thanks @D0000M!

I do think that current design is great, except in one small but important detail: `TransactionalEnforcer` is generally shared between multiple goroutines (e.g., API request handlers) and current design that `BeginTransaction` returns an error if any other transaction is in progress. I think this is problematic because it is unclear what to do in such an error? You cannot simply retry or something. It would be better to block (but that would make everything serialized around transactions, which is also not the best) or to support multiple transactions in parallel.@D0000M: Thank you for your feedback! This is a key point and I'm actively working on it. it's coming soon!

Solution patch:
diff --git a/enforcer_transactional.go b/enforcer_transactional.go
new file mode 100644
index 0000000..179bd3a
--- /dev/null
+++ b/enforcer_transactional.go
@@ -0,0 +1,126 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package casbin
+
+import (
+	"context"
+	"errors"
+	"sync"
+
+	"github.com/casbin/casbin/v2/persist"
+)
+
+// TransactionalEnforcer extends Enforcer with transaction support.
+// It provides atomic policy operations through transactions.
+type TransactionalEnforcer struct {
+	*Enforcer              // Embedded enforcer for all standard functionality
+	currentTx *Transaction // Current active transaction (nil if none)
+	txMutex   sync.RWMutex // Protects transaction state
+}
+
+// NewTransactionalEnforcer creates a new TransactionalEnforcer.
+// It accepts the same parameters as NewEnforcer.
+func NewTransactionalEnforcer(params ...interface{}) (*TransactionalEnforcer, error) {
+	enforcer, err := NewEnforcer(params...)
+	if err != nil {
+		return nil, err
+	}
+
+	return &TransactionalEnforcer{
+		Enforcer: enforcer,
+	}, nil
+}
+
+// BeginTransaction starts a new transaction.
+// Returns an error if a transaction is already in progress or if the adapter doesn't support transactions.
+func (te *TransactionalEnforcer) BeginTransaction(ctx context.Context) (*Transaction, error) {
+	te.txMutex.Lock()
+	defer te.txMutex.Unlock()
+
+	if te.currentTx != nil {
+		return nil, errors.New("transaction already in progress")
+	}
+
+	// Check if adapter supports transactions.
+	txAdapter, ok := te.adapter.(persist.TransactionalAdapter)
+	if !ok {
+		return nil, errors.New("adapter does not support transactions")
+	}
+
+	// Start database transaction.
+	txContext, err := txAdapter.BeginTransaction(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	// Create transaction buffer with current model snapshot.
+	buffer := NewTransactionBuffer(te.model)
+
+	tx := &Transaction{
+		enforcer:  te,
+		buffer:    buffer,
+		txContext: txContext,
+		ctx:       ctx,
+	}
+
+	te.currentTx = tx
+	return tx, nil
+}
+
+// GetCurrentTransaction returns the current active transaction, or nil if none.
+func (te *TransactionalEnforcer) GetCurrentTransaction() *Transaction {
+	te.txMutex.RLock()
+	defer te.txMutex.RUnlock()
+	return te.currentTx
+}
+
+// IsInTransaction returns true if there is an active transaction.
+func (te *TransactionalEnforcer) IsInTransaction() bool {
+	te.txMutex.RLock()
+	defer te.txMutex.RUnlock()
+	return te.currentTx != nil
+}
+
+// clearTransaction clears the current transaction (called internally).
+func (te *TransactionalEnforcer) clearTransaction() {
+	te.txMutex.Lock()
+	defer te.txMutex.Unlock()
+	te.currentTx = nil
+}
+
+// WithTransaction executes a function within a transaction.
+// If the function returns an error, the transaction is rolled back.
+// Otherwise, it's committed automatically.
+func (te *TransactionalEnforcer) WithTransaction(ctx context.Context, fn func(*Transaction) error) error {
+	tx, err := te.BeginTransaction(ctx)
+	if err != nil {
+		return err
+	}
+
+	defer func() {
+		if r := recover(); r != nil {
+			_ = tx.Rollback()
+			panic(r)
+		}
+	}()
+
+	err = fn(tx)
+	if err != nil {
+		_ = tx.Rollback()
+		return err
+	}
+
+	return tx.Commit()
+}
diff --git a/persist/transaction.go b/persist/transaction.go
new file mode 100644
index 0000000..0b69e47
--- /dev/null
+++ b/persist/transaction.go
@@ -0,0 +1,58 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package persist
+
+import "context"
+
+// TransactionalAdapter defines the interface for adapters that support transactions.
+// Adapters implementing this interface can participate in Casbin transactions.
+type TransactionalAdapter interface {
+	Adapter
+	// BeginTransaction starts a new transaction and returns a transaction context.
+	BeginTransaction(ctx context.Context) (TransactionContext, error)
+}
+
+// TransactionContext represents a database transaction context.
+// It provides methods to commit or rollback the transaction and get an adapter
+// that operates within this transaction.
+type TransactionContext interface {
+	// Commit commits the transaction.
+	Commit() error
+	// Rollback rolls back the transaction.
+	Rollback() error
+	// GetAdapter returns an adapter that operates within this transaction.
+	GetAdapter() Adapter
+}
+
+// PolicyOperation represents a policy operation that can be buffered in a transaction.
+type PolicyOperation struct {
+	Type       OperationType // The type of operation (add, remove, update)
+	Section    string        // The section of the policy (p, g)
+	PolicyType string        // The policy type (p, p2, g, g2, etc.)
+	Rules      [][]string    // The policy rules to operate on
+	OldRules   [][]string    // For update operations, the old rules to replace
+}
+
+// OperationType represents the type of policy operation.
+type OperationType int
+
+const (
+	// OperationAdd represents adding policy rules.
+	OperationAdd OperationType = iota
+	// OperationRemove represents removing policy rules.
+	OperationRemove
+	// OperationUpdate represents updating policy rules.
+	OperationUpdate
+)
diff --git a/transaction.go b/transaction.go
new file mode 100644
index 0000000..e59e3f2
--- /dev/null
+++ b/transaction.go
@@ -0,0 +1,403 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package casbin
+
+import (
+	"context"
+	"errors"
+	"sync"
+
+	"github.com/casbin/casbin/v2/model"
+	"github.com/casbin/casbin/v2/persist"
+)
+
+// Transaction represents a Casbin transaction.
+// It provides methods to perform policy operations within a transaction
+// and commit or rollback all changes atomically.
+type Transaction struct {
+	enforcer   *TransactionalEnforcer     // Reference to the transactional enforcer
+	buffer     *TransactionBuffer         // Buffer for policy operations
+	txContext  persist.TransactionContext // Database transaction context
+	ctx        context.Context            // Context for the transaction
+	committed  bool                       // Whether the transaction has been committed
+	rolledBack bool                       // Whether the transaction has been rolled back
+	mutex      sync.RWMutex               // Protects transaction state
+}
+
+// AddPolicy adds a policy within the transaction.
+// The policy is buffered and will be applied when the transaction is committed.
+func (tx *Transaction) AddPolicy(params ...interface{}) (bool, error) {
+	return tx.AddNamedPolicy("p", params...)
+}
+
+// AddNamedPolicy adds a named policy within the transaction.
+// The policy is buffered and will be applied when the transaction is committed.
+func (tx *Transaction) AddNamedPolicy(ptype string, params ...interface{}) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	var rule []string
+	if strSlice, ok := params[0].([]string); len(params) == 1 && ok {
+		rule = make([]string, len(strSlice))
+		copy(rule, strSlice)
+	} else {
+		rule = make([]string, 0, len(params))
+		for _, param := range params {
+			rule = append(rule, param.(string))
+		}
+	}
+
+	// Check if policy already exists in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	hasPolicy, err := bufferedModel.HasPolicy("p", ptype, rule)
+	if hasPolicy || err != nil {
+		return false, err
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationAdd,
+		Section:    "p",
+		PolicyType: ptype,
+		Rules:      [][]string{rule},
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// AddPolicies adds multiple policies within the transaction.
+func (tx *Transaction) AddPolicies(rules [][]string) (bool, error) {
+	return tx.AddNamedPolicies("p", rules)
+}
+
+// AddNamedPolicies adds multiple named policies within the transaction.
+func (tx *Transaction) AddNamedPolicies(ptype string, rules [][]string) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	if len(rules) == 0 {
+		return false, nil
+	}
+
+	// Check if any policies already exist in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	var validRules [][]string
+	for _, rule := range rules {
+		hasPolicy, err := bufferedModel.HasPolicy("p", ptype, rule)
+		if err != nil {
+			return false, err
+		}
+		if !hasPolicy {
+			validRules = append(validRules, rule)
+		}
+	}
+
+	if len(validRules) == 0 {
+		return false, nil
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationAdd,
+		Section:    "p",
+		PolicyType: ptype,
+		Rules:      validRules,
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// RemovePolicy removes a policy within the transaction.
+func (tx *Transaction) RemovePolicy(params ...interface{}) (bool, error) {
+	return tx.RemoveNamedPolicy("p", params...)
+}
+
+// RemoveNamedPolicy removes a named policy within the transaction.
+func (tx *Transaction) RemoveNamedPolicy(ptype string, params ...interface{}) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	var rule []string
+	if strSlice, ok := params[0].([]string); len(params) == 1 && ok {
+		rule = make([]string, len(strSlice))
+		copy(rule, strSlice)
+	} else {
+		rule = make([]string, 0, len(params))
+		for _, param := range params {
+			rule = append(rule, param.(string))
+		}
+	}
+
+	// Check if policy exists in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	hasPolicy, err := bufferedModel.HasPolicy("p", ptype, rule)
+	if !hasPolicy || err != nil {
+		return false, err
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationRemove,
+		Section:    "p",
+		PolicyType: ptype,
+		Rules:      [][]string{rule},
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// RemovePolicies removes multiple policies within the transaction.
+func (tx *Transaction) RemovePolicies(rules [][]string) (bool, error) {
+	return tx.RemoveNamedPolicies("p", rules)
+}
+
+// RemoveNamedPolicies removes multiple named policies within the transaction.
+func (tx *Transaction) RemoveNamedPolicies(ptype string, rules [][]string) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	if len(rules) == 0 {
+		return false, nil
+	}
+
+	// Check if policies exist in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	var validRules [][]string
+	for _, rule := range rules {
+		hasPolicy, err := bufferedModel.HasPolicy("p", ptype, rule)
+		if err != nil {
+			return false, err
+		}
+		if hasPolicy {
+			validRules = append(validRules, rule)
+		}
+	}
+
+	if len(validRules) == 0 {
+		return false, nil
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationRemove,
+		Section:    "p",
+		PolicyType: ptype,
+		Rules:      validRules,
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// UpdatePolicy updates a policy within the transaction.
+func (tx *Transaction) UpdatePolicy(oldPolicy []string, newPolicy []string) (bool, error) {
+	return tx.UpdateNamedPolicy("p", oldPolicy, newPolicy)
+}
+
+// UpdateNamedPolicy updates a named policy within the transaction.
+func (tx *Transaction) UpdateNamedPolicy(ptype string, oldPolicy []string, newPolicy []string) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	// Check if old policy exists and new policy doesn't exist.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	hasOldPolicy, err := bufferedModel.HasPolicy("p", ptype, oldPolicy)
+	if !hasOldPolicy || err != nil {
+		return false, err
+	}
+
+	hasNewPolicy, err := bufferedModel.HasPolicy("p", ptype, newPolicy)
+	if hasNewPolicy || err != nil {
+		return false, err
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationUpdate,
+		Section:    "p",
+		PolicyType: ptype,
+		Rules:      [][]string{newPolicy},
+		OldRules:   [][]string{oldPolicy},
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// AddGroupingPolicy adds a grouping policy within the transaction.
+func (tx *Transaction) AddGroupingPolicy(params ...interface{}) (bool, error) {
+	return tx.AddNamedGroupingPolicy("g", params...)
+}
+
+// AddNamedGroupingPolicy adds a named grouping policy within the transaction.
+func (tx *Transaction) AddNamedGroupingPolicy(ptype string, params ...interface{}) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	var rule []string
+	if strSlice, ok := params[0].([]string); len(params) == 1 && ok {
+		rule = make([]string, len(strSlice))
+		copy(rule, strSlice)
+	} else {
+		rule = make([]string, 0, len(params))
+		for _, param := range params {
+			rule = append(rule, param.(string))
+		}
+	}
+
+	// Check if grouping policy already exists in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	hasPolicy, err := bufferedModel.HasPolicy("g", ptype, rule)
+	if hasPolicy || err != nil {
+		return false, err
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationAdd,
+		Section:    "g",
+		PolicyType: ptype,
+		Rules:      [][]string{rule},
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// RemoveGroupingPolicy removes a grouping policy within the transaction.
+func (tx *Transaction) RemoveGroupingPolicy(params ...interface{}) (bool, error) {
+	return tx.RemoveNamedGroupingPolicy("g", params...)
+}
+
+// RemoveNamedGroupingPolicy removes a named grouping policy within the transaction.
+func (tx *Transaction) RemoveNamedGroupingPolicy(ptype string, params ...interface{}) (bool, error) {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed || tx.rolledBack {
+		return false, errors.New("transaction is not active")
+	}
+
+	var rule []string
+	if strSlice, ok := params[0].([]string); len(params) == 1 && ok {
+		rule = make([]string, len(strSlice))
+		copy(rule, strSlice)
+	} else {
+		rule = make([]string, 0, len(params))
+		for _, param := range params {
+			rule = append(rule, param.(string))
+		}
+	}
+
+	// Check if grouping policy exists in the buffered model.
+	bufferedModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return false, err
+	}
+
+	hasPolicy, err := bufferedModel.HasPolicy("g", ptype, rule)
+	if !hasPolicy || err != nil {
+		return false, err
+	}
+
+	// Add operation to buffer.
+	op := persist.PolicyOperation{
+		Type:       persist.OperationRemove,
+		Section:    "g",
+		PolicyType: ptype,
+		Rules:      [][]string{rule},
+	}
+	tx.buffer.AddOperation(op)
+
+	return true, nil
+}
+
+// GetBufferedModel returns the model as it would look after applying all buffered operations.
+// This is useful for preview or validation purposes within the transaction.
+func (tx *Transaction) GetBufferedModel() (model.Model, error) {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+
+	if tx.committed || tx.rolledBack {
+		return nil, errors.New("transaction is not active")
+	}
+
+	return tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+}
+
+// HasOperations returns true if the transaction has any buffered operations.
+func (tx *Transaction) HasOperations() bool {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+	return tx.buffer.HasOperations()
+}
+
+// OperationCount returns the number of buffered operations in the transaction.
+func (tx *Transaction) OperationCount() int {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+	return tx.buffer.OperationCount()
+}
diff --git a/transaction_buffer.go b/transaction_buffer.go
new file mode 100644
index 0000000..08001b7
--- /dev/null
+++ b/transaction_buffer.go
@@ -0,0 +1,131 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package casbin
+
+import (
+	"sync"
+
+	"github.com/casbin/casbin/v2/model"
+	"github.com/casbin/casbin/v2/persist"
+)
+
+// TransactionBuffer holds all policy changes made within a transaction.
+// It maintains a list of operations and a snapshot of the model state
+// at the beginning of the transaction.
+type TransactionBuffer struct {
+	operations    []persist.PolicyOperation // Buffered operations
+	modelSnapshot model.Model               // Model state at transaction start
+	mutex         sync.RWMutex              // Protects concurrent access
+}
+
+// NewTransactionBuffer creates a new transaction buffer with a model snapshot.
+// The snapshot represents the state of the model at the beginning of the transaction.
+func NewTransactionBuffer(baseModel model.Model) *TransactionBuffer {
+	return &TransactionBuffer{
+		operations:    make([]persist.PolicyOperation, 0),
+		modelSnapshot: baseModel.Copy(),
+	}
+}
+
+// AddOperation adds a policy operation to the buffer.
+// This operation will be applied when the transaction is committed.
+func (tb *TransactionBuffer) AddOperation(op persist.PolicyOperation) {
+	tb.mutex.Lock()
+	defer tb.mutex.Unlock()
+	tb.operations = append(tb.operations, op)
+}
+
+// GetOperations returns all buffered operations.
+// Returns a copy to prevent external modifications.
+func (tb *TransactionBuffer) GetOperations() []persist.PolicyOperation {
+	tb.mutex.RLock()
+	defer tb.mutex.RUnlock()
+
+	// Return a copy to prevent external modifications.
+	result := make([]persist.PolicyOperation, len(tb.operations))
+	copy(result, tb.operations)
+	return result
+}
+
+// Clear removes all buffered operations.
+// This is typically called after a successful commit or rollback.
+func (tb *TransactionBuffer) Clear() {
+	tb.mutex.Lock()
+	defer tb.mutex.Unlock()
+	tb.operations = tb.operations[:0]
+}
+
+// GetModelSnapshot returns the model snapshot taken at transaction start.
+// This represents the original state before any transaction operations.
+func (tb *TransactionBuffer) GetModelSnapshot() model.Model {
+	tb.mutex.RLock()
+	defer tb.mutex.RUnlock()
+	return tb.modelSnapshot.Copy()
+}
+
+// ApplyOperationsToModel applies all buffered operations to a model and returns the result.
+// This simulates what the model would look like after all operations are applied.
+// It's used for validation and preview purposes within the transaction.
+func (tb *TransactionBuffer) ApplyOperationsToModel(baseModel model.Model) (model.Model, error) {
+	tb.mutex.RLock()
+	defer tb.mutex.RUnlock()
+
+	resultModel := baseModel.Copy()
+
+	for _, op := range tb.operations {
+		switch op.Type {
+		case persist.OperationAdd:
+			for _, rule := range op.Rules {
+				if err := resultModel.AddPolicy(op.Section, op.PolicyType, rule); err != nil {
+					return nil, err
+				}
+			}
+		case persist.OperationRemove:
+			for _, rule := range op.Rules {
+				if _, err := resultModel.RemovePolicy(op.Section, op.PolicyType, rule); err != nil {
+					return nil, err
+				}
+			}
+		case persist.OperationUpdate:
+			// For update operations, remove old rules and add new ones.
+			for i, oldRule := range op.OldRules {
+				if i < len(op.Rules) {
+					if _, err := resultModel.RemovePolicy(op.Section, op.PolicyType, oldRule); err != nil {
+						return nil, err
+					}
+					if err := resultModel.AddPolicy(op.Section, op.PolicyType, op.Rules[i]); err != nil {
+						return nil, err
+					}
+				}
+			}
+		}
+	}
+
+	return resultModel, nil
+}
+
+// HasOperations returns true if there are any buffered operations.
+func (tb *TransactionBuffer) HasOperations() bool {
+	tb.mutex.RLock()
+	defer tb.mutex.RUnlock()
+	return len(tb.operations) > 0
+}
+
+// OperationCount returns the number of buffered operations.
+func (tb *TransactionBuffer) OperationCount() int {
+	tb.mutex.RLock()
+	defer tb.mutex.RUnlock()
+	return len(tb.operations)
+}
diff --git a/transaction_commit.go b/transaction_commit.go
new file mode 100644
index 0000000..8e3da71
--- /dev/null
+++ b/transaction_commit.go
@@ -0,0 +1,228 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package casbin
+
+import (
+	"errors"
+
+	"github.com/casbin/casbin/v2/persist"
+)
+
+// Commit commits the transaction using a two-phase commit protocol.
+// Phase 1: Apply all operations to the database
+// Phase 2: Apply changes to the in-memory model and rebuild role links
+func (tx *Transaction) Commit() error {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed {
+		return errors.New("transaction already committed")
+	}
+	if tx.rolledBack {
+		return errors.New("transaction already rolled back")
+	}
+
+	// If no operations, just commit the database transaction and clear state.
+	if !tx.buffer.HasOperations() {
+		if err := tx.txContext.Commit(); err != nil {
+			return err
+		}
+		tx.committed = true
+		tx.enforcer.clearTransaction()
+		return nil
+	}
+
+	// Phase 1: Apply all buffered operations to the database
+	if err := tx.applyOperationsToDatabase(); err != nil {
+		// Rollback database transaction on failure.
+		_ = tx.txContext.Rollback()
+		return err
+	}
+
+	// Commit database transaction.
+	if err := tx.txContext.Commit(); err != nil {
+		return err
+	}
+
+	// Phase 2: Apply changes to the in-memory model
+	if err := tx.applyOperationsToModel(); err != nil {
+		// At this point, database is committed but model update failed.
+		// This is a critical error that should not happen in normal circumstances.
+		return errors.New("critical error: database committed but model update failed: " + err.Error())
+	}
+
+	tx.committed = true
+	tx.enforcer.clearTransaction()
+
+	return nil
+}
+
+// Rollback rolls back the transaction.
+// This will rollback the database transaction and clear the transaction state.
+func (tx *Transaction) Rollback() error {
+	tx.mutex.Lock()
+	defer tx.mutex.Unlock()
+
+	if tx.committed {
+		return errors.New("transaction already committed")
+	}
+	if tx.rolledBack {
+		return errors.New("transaction already rolled back")
+	}
+
+	// Rollback database transaction.
+	if err := tx.txContext.Rollback(); err != nil {
+		return err
+	}
+
+	tx.rolledBack = true
+	tx.enforcer.clearTransaction()
+
+	return nil
+}
+
+// applyOperationsToDatabase applies all buffered operations to the database.
+func (tx *Transaction) applyOperationsToDatabase() error {
+	operations := tx.buffer.GetOperations()
+	txAdapter := tx.txContext.GetAdapter()
+
+	for _, op := range operations {
+		switch op.Type {
+		case persist.OperationAdd:
+			if err := tx.applyAddOperationToDatabase(txAdapter, op); err != nil {
+				return err
+			}
+		case persist.OperationRemove:
+			if err := tx.applyRemoveOperationToDatabase(txAdapter, op); err != nil {
+				return err
+			}
+		case persist.OperationUpdate:
+			if err := tx.applyUpdateOperationToDatabase(txAdapter, op); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// applyAddOperationToDatabase applies an add operation to the database.
+func (tx *Transaction) applyAddOperationToDatabase(adapter persist.Adapter, op persist.PolicyOperation) error {
+	if batchAdapter, ok := adapter.(persist.BatchAdapter); ok {
+		// Use batch operation if available.
+		return batchAdapter.AddPolicies(op.Section, op.PolicyType, op.Rules)
+	} else {
+		// Fall back to individual operations.
+		for _, rule := range op.Rules {
+			if err := adapter.AddPolicy(op.Section, op.PolicyType, rule); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// applyRemoveOperationToDatabase applies a remove operation to the database.
+func (tx *Transaction) applyRemoveOperationToDatabase(adapter persist.Adapter, op persist.PolicyOperation) error {
+	if batchAdapter, ok := adapter.(persist.BatchAdapter); ok {
+		// Use batch operation if available.
+		return batchAdapter.RemovePolicies(op.Section, op.PolicyType, op.Rules)
+	} else {
+		// Fall back to individual operations.
+		for _, rule := range op.Rules {
+			if err := adapter.RemovePolicy(op.Section, op.PolicyType, rule); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// applyUpdateOperationToDatabase applies an update operation to the database.
+func (tx *Transaction) applyUpdateOperationToDatabase(adapter persist.Adapter, op persist.PolicyOperation) error {
+	if updateAdapter, ok := adapter.(persist.UpdatableAdapter); ok {
+		// Use update operation if available.
+		return updateAdapter.UpdatePolicies(op.Section, op.PolicyType, op.OldRules, op.Rules)
+	} else {
+		// Fall back to remove + add.
+		for i, oldRule := range op.OldRules {
+			if i < len(op.Rules) {
+				if err := adapter.RemovePolicy(op.Section, op.PolicyType, oldRule); err != nil {
+					return err
+				}
+				if err := adapter.AddPolicy(op.Section, op.PolicyType, op.Rules[i]); err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
+// applyOperationsToModel applies all buffered operations to the in-memory model.
+func (tx *Transaction) applyOperationsToModel() error {
+	// Create new model with all operations applied.
+	newModel, err := tx.buffer.ApplyOperationsToModel(tx.buffer.GetModelSnapshot())
+	if err != nil {
+		return err
+	}
+
+	// Replace the enforcer's model.
+	tx.enforcer.model = newModel
+	tx.enforcer.invalidateMatcherMap()
+
+	// Rebuild role links if necessary.
+	if tx.enforcer.autoBuildRoleLinks {
+		// Check if any operations involved grouping policies.
+		operations := tx.buffer.GetOperations()
+		needRoleRebuild := false
+
+		for _, op := range operations {
+			if op.Section == "g" {
+				needRoleRebuild = true
+				break
+			}
+		}
+
+		if needRoleRebuild {
+			if err := tx.enforcer.BuildRoleLinks(); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// IsCommitted returns true if the transaction has been committed.
+func (tx *Transaction) IsCommitted() bool {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+	return tx.committed
+}
+
+// IsRolledBack returns true if the transaction has been rolled back.
+func (tx *Transaction) IsRolledBack() bool {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+	return tx.rolledBack
+}
+
+// IsActive returns true if the transaction is still active (not committed or rolled back).
+func (tx *Transaction) IsActive() bool {
+	tx.mutex.RLock()
+	defer tx.mutex.RUnlock()
+	return !tx.committed && !tx.rolledBack
+}



Test patch:
diff --git a/transaction_test.go b/transaction_test.go
new file mode 100644
index 0000000..31531e4
--- /dev/null
+++ b/transaction_test.go
@@ -0,0 +1,386 @@
+// Copyright 2025 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package casbin
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/casbin/casbin/v2/model"
+	"github.com/casbin/casbin/v2/persist"
+)
+
+// MockTransactionalAdapter is a mock adapter that implements TransactionalAdapter for testing.
+type MockTransactionalAdapter struct {
+	policies map[string]map[string][][]string // section -> ptype -> rules
+	tx       *MockTransactionContext
+}
+
+// MockTransactionContext is a mock transaction context for testing.
+type MockTransactionContext struct {
+	adapter    *MockTransactionalAdapter
+	committed  bool
+	rolledBack bool
+}
+
+// NewMockTransactionalAdapter creates a new mock transactional adapter.
+func NewMockTransactionalAdapter() *MockTransactionalAdapter {
+	return &MockTransactionalAdapter{
+		policies: make(map[string]map[string][][]string),
+	}
+}
+
+// LoadPolicy loads policy from the mock storage.
+func (a *MockTransactionalAdapter) LoadPolicy(model model.Model) error {
+	// Load policies from mock storage.
+	for section, ptypes := range a.policies {
+		for ptype, rules := range ptypes {
+			for _, rule := range rules {
+				if err := model.AddPolicy(section, ptype, rule); err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
+// SavePolicy saves policy to the mock storage.
+func (a *MockTransactionalAdapter) SavePolicy(model model.Model) error {
+	a.policies = make(map[string]map[string][][]string)
+
+	// Save p policies.
+	if pSection, ok := model["p"]; ok {
+		for ptype, ast := range pSection {
+			if a.policies["p"] == nil {
+				a.policies["p"] = make(map[string][][]string)
+			}
+			a.policies["p"][ptype] = ast.Policy
+		}
+	}
+
+	// Save g policies.
+	if gSection, ok := model["g"]; ok {
+		for ptype, ast := range gSection {
+			if a.policies["g"] == nil {
+				a.policies["g"] = make(map[string][][]string)
+			}
+			a.policies["g"][ptype] = ast.Policy
+		}
+	}
+
+	return nil
+}
+
+// AddPolicy adds a policy rule.
+func (a *MockTransactionalAdapter) AddPolicy(sec string, ptype string, rule []string) error {
+	if a.policies[sec] == nil {
+		a.policies[sec] = make(map[string][][]string)
+	}
+	a.policies[sec][ptype] = append(a.policies[sec][ptype], rule)
+	return nil
+}
+
+// RemovePolicy removes a policy rule.
+func (a *MockTransactionalAdapter) RemovePolicy(sec string, ptype string, rule []string) error {
+	if a.policies[sec] == nil || a.policies[sec][ptype] == nil {
+		return nil
+	}
+
+	for i, existingRule := range a.policies[sec][ptype] {
+		if len(existingRule) == len(rule) {
+			match := true
+			for j, v := range rule {
+				if existingRule[j] != v {
+					match = false
+					break
+				}
+			}
+			if match {
+				a.policies[sec][ptype] = append(a.policies[sec][ptype][:i], a.policies[sec][ptype][i+1:]...)
+				break
+			}
+		}
+	}
+	return nil
+}
+
+// RemoveFilteredPolicy removes policy rules that match the filter.
+func (a *MockTransactionalAdapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error {
+	// Simple implementation for testing.
+	return nil
+}
+
+// BeginTransaction starts a transaction.
+func (a *MockTransactionalAdapter) BeginTransaction(ctx context.Context) (persist.TransactionContext, error) {
+	a.tx = &MockTransactionContext{
+		adapter: a,
+	}
+	return a.tx, nil
+}
+
+// Commit commits the transaction.
+func (tx *MockTransactionContext) Commit() error {
+	tx.committed = true
+	return nil
+}
+
+// Rollback rolls back the transaction.
+func (tx *MockTransactionContext) Rollback() error {
+	tx.rolledBack = true
+	return nil
+}
+
+// GetAdapter returns the adapter within the transaction.
+func (tx *MockTransactionContext) GetAdapter() persist.Adapter {
+	return tx.adapter
+}
+
+// Test basic transaction functionality.
+func TestTransactionBasicOperations(t *testing.T) {
+	adapter := NewMockTransactionalAdapter()
+
+	// Create transactional enforcer.
+	e, err := NewTransactionalEnforcer("examples/rbac_model.conf", adapter)
+	if err != nil {
+		t.Fatalf("Failed to create transactional enforcer: %v", err)
+	}
+
+	ctx := context.Background()
+
+	// Begin transaction.
+	tx, err := e.BeginTransaction(ctx)
+	if err != nil {
+		t.Fatalf("Failed to begin transaction: %v", err)
+	}
+
+	// Add policies in transaction.
+	ok, err := tx.AddPolicy("alice", "data1", "read")
+	if !ok || err != nil {
+		t.Fatalf("Failed to add policy in transaction: %v", err)
+	}
+
+	ok, err = tx.AddPolicy("bob", "data2", "write")
+	if !ok || err != nil {
+		t.Fatalf("Failed to add policy in transaction: %v", err)
+	}
+
+	// Check that policies are not yet in the enforcer.
+	if has, _ := e.HasPolicy("alice", "data1", "read"); has {
+		t.Fatal("Policy should not be in enforcer before commit")
+	}
+
+	// Commit transaction.
+	if err := tx.Commit(); err != nil {
+		t.Fatalf("Failed to commit transaction: %v", err)
+	}
+
+	// Check that policies are now in the enforcer.
+	if has, _ := e.HasPolicy("alice", "data1", "read"); !has {
+		t.Fatal("Policy should be in enforcer after commit")
+	}
+
+	if has, _ := e.HasPolicy("bob", "data2", "write"); !has {
+		t.Fatal("Policy should be in enforcer after commit")
+	}
+}
+
+// Test transaction rollback.
+func TestTransactionRollback(t *testing.T) {
+	adapter := NewMockTransactionalAdapter()
+
+	e, err := NewTransactionalEnforcer("examples/rbac_model.conf", adapter)
+	if err != nil {
+		t.Fatalf("Failed to create transactional enforcer: %v", err)
+	}
+
+	// Add initial policy.
+	e.AddPolicy("alice", "data1", "read")
+
+	ctx := context.Background()
+
+	// Begin transaction.
+	tx, err := e.BeginTransaction(ctx)
+	if err != nil {
+		t.Fatalf("Failed to begin transaction: %v", err)
+	}
+
+	// Add policy in transaction.
+	ok, err := tx.AddPolicy("bob", "data2", "write")
+	if !ok || err != nil {
+		t.Fatalf("Failed to add policy in transaction: %v", err)
+	}
+
+	// Remove existing policy in transaction.
+	ok, err = tx.RemovePolicy("alice", "data1", "read")
+	if !ok || err != nil {
+		t.Fatalf("Failed to remove policy in transaction: %v", err)
+	}
+
+	// Rollback transaction.
+	if err := tx.Rollback(); err != nil {
+		t.Fatalf("Failed to rollback transaction: %v", err)
+	}
+
+	// Check that original state is preserved.
+	if has, _ := e.HasPolicy("alice", "data1", "read"); !has {
+		t.Fatal("Original policy should still exist after rollback")
+	}
+
+	if has, _ := e.HasPolicy("bob", "data2", "write"); has {
+		t.Fatal("New policy should not exist after rollback")
+	}
+}
+
+// Test multiple transactions.
+func TestMultipleTransactions(t *testing.T) {
+	adapter := NewMockTransactionalAdapter()
+
+	e, err := NewTransactionalEnforcer("examples/rbac_model.conf", adapter)
+	if err != nil {
+		t.Fatalf("Failed to create transactional enforcer: %v", err)
+	}
+
+	ctx := context.Background()
+
+	// First transaction.
+	tx1, err := e.BeginTransaction(ctx)
+	if err != nil {
+		t.Fatalf("Failed to begin first transaction: %v", err)
+	}
+
+	tx1.AddPolicy("alice", "data1", "read")
+
+	if err := tx1.Commit(); err != nil {
+		t.Fatalf("Failed to commit first transaction: %v", err)
+	}
+
+	// Second transaction.
+	tx2, err := e.BeginTransaction(ctx)
+	if err != nil {
+		t.Fatalf("Failed to begin second transaction: %v", err)
+	}
+
+	tx2.AddPolicy("bob", "data2", "write")
+
+	if err := tx2.Commit(); err != nil {
+		t.Fatalf("Failed to commit second transaction: %v", err)
+	}
+
+	// Check both policies exist.
+	if has, _ := e.HasPolicy("alice", "data1", "read"); !has {
+		t.Fatal("First policy should exist")
+	}
+
+	if has, _ := e.HasPolicy("bob", "data2", "write"); !has {
+		t.Fatal("Second policy should exist")
+	}
+}
+
+// Test WithTransaction helper method.
+func TestWithTransaction(t *testing.T) {
+	adapter := NewMockTransactionalAdapter()
+
+	e, err := NewTransactionalEnforcer("examples/rbac_model.conf", adapter)
+	if err != nil {
+		t.Fatalf("Failed to create transactional enforcer: %v", err)
+	}
+
+	ctx := context.Background()
+
+	// Test successful transaction.
+	err = e.WithTransaction(ctx, func(tx *Transaction) error {
+		tx.AddPolicy("alice", "data1", "read")
+		tx.AddPolicy("bob", "data2", "write")
+		return nil
+	})
+
+	if err != nil {
+		t.Fatalf("WithTransaction failed: %v", err)
+	}
+
+	// Check policies exist.
+	if has, _ := e.HasPolicy("alice", "data1", "read"); !has {
+		t.Fatal("Policy should exist after successful WithTransaction")
+	}
+
+	// Test failed transaction (should rollback).
+	err = e.WithTransaction(ctx, func(tx *Transaction) error {
+		tx.AddPolicy("charlie", "data3", "read")
+		return errors.New("simulated error")
+	})
+
+	if err == nil {
+		t.Fatal("WithTransaction should have returned an error")
+	}
+
+	// Check that policy was not added due to rollback.
+	if has, _ := e.HasPolicy("charlie", "data3", "read"); has {
+		t.Fatal("Policy should not exist after failed WithTransaction")
+	}
+}
+
+// Test transaction buffer operations.
+func TestTransactionBuffer(t *testing.T) {
+	adapter := NewMockTransactionalAdapter()
+
+	e, err := NewTransactionalEnforcer("examples/rbac_model.conf", adapter)
+	if err != nil {
+		t.Fatalf("Failed to create transactional enforcer: %v", err)
+	}
+
+	ctx := context.Background()
+
+	tx, err := e.BeginTransaction(ctx)
+	if err != nil {
+		t.Fatalf("Failed to begin transaction: %v", err)
+	}
+
+	// Initially no operations.
+	if tx.HasOperations() {
+		t.Fatal("Transaction should have no operations initially")
+	}
+
+	if tx.OperationCount() != 0 {
+		t.Fatal("Operation count should be 0 initially")
+	}
+
+	// Add some operations.
+	tx.AddPolicy("alice", "data1", "read")
+	tx.AddPolicy("bob", "data2", "write")
+
+	if !tx.HasOperations() {
+		t.Fatal("Transaction should have operations")
+	}
+
+	if tx.OperationCount() != 2 {
+		t.Fatalf("Expected 2 operations, got %d", tx.OperationCount())
+	}
+
+	// Get buffered model.
+	bufferedModel, err := tx.GetBufferedModel()
+	if err != nil {
+		t.Fatalf("Failed to get buffered model: %v", err)
+	}
+
+	// Check that buffered model contains the policies.
+	hasPolicy, _ := bufferedModel.HasPolicy("p", "p", []string{"alice", "data1", "read"})
+	if !hasPolicy {
+		t.Fatal("Buffered model should contain the added policy")
+	}
+
+	tx.Rollback()
+}