Skip to content

feat: tenant-scoped policy loading for GORM adapter (filter + per-tenant table)#14

Merged
intel352 merged 4 commits intomainfrom
copilot/feat-tenant-scoped-policy-loading
Mar 12, 2026
Merged

feat: tenant-scoped policy loading for GORM adapter (filter + per-tenant table)#14
intel352 merged 4 commits intomainfrom
copilot/feat-tenant-scoped-policy-loading

Conversation

Copy link
Contributor

Copilot AI commented Mar 12, 2026

Multi-tenant apps using the GORM adapter currently load all tenants' policies into every enforcer. This adds two independent isolation strategies.

Option A – Column filter (filter_field / filter_value)

Applies a WHERE <col> = <value> clause on every LoadPolicy, isolating one tenant's rows in a shared table.

adapter:
  type: gorm
  driver: sqlite3
  dsn: "file:authz.db"
  filter_field: v0          # column holding the tenant identifier
  filter_value: acme_corp

Implements persist.FilteredAdapter. IsFiltered() starts false so Casbin calls LoadPolicy during NewEnforcer init (Casbin skips LoadPolicy when IsFiltered()=true at construction), then flips true after the first filtered load to prevent accidental bulk SavePolicy from clobbering other tenants. SavePolicy with an active filter deletes/reinserts only the scoped rows. CasbinModule mutation methods skip the redundant enforcer.SavePolicy() call when IsFiltered() is true.

Option B – Per-tenant table (table_name template)

table_name now accepts Go templates resolved at adapter-creation time. The new tenant field is exposed as {{.Tenant}}.

adapter:
  type: gorm
  driver: sqlite3
  dsn: "file:authz.db"
  table_name: "casbin_rule_{{.Tenant}}"
  tenant: acme_corp         # resolves to table casbin_rule_acme_corp

Also fixes a pre-existing bug where tableName was stored on gormAdapter but never used — all queries were hitting the struct's default casbin_rule table.

SQLite migrator fixes

The custom sqliteMigrator previously fell back to GORM's generic migrator for HasTable, HasIndex, and HasColumn, all of which query information_schema (non-existent in SQLite), causing silent failures and data loss when a second connection called AutoMigrate on an existing table.

  • HasTable / HasIndex / HasColumn now query sqlite_master / pragma_table_info
  • migrateTable only runs AutoMigrate for new tables; the composite unique index is created separately with a per-table name (uidx_<tableName>) to avoid SQLite's global index-name namespace conflict across multiple tenant tables

Security

  • filter_field validated against whitelist {v0…v5}; backtick-quoted in the generated WHERE clause as defence-in-depth
  • table_name validated to allow only [a-zA-Z0-9_-] before use in raw CREATE INDEX SQL
Original prompt

This section details on the original issue you should resolve

<issue_title>feat: tenant-scoped policy loading for GORM adapter</issue_title>
<issue_description>## Problem

When using the GORM adapter with a database, all policies are loaded into a single enforcer from a single casbin_rule table. In multi-tenant applications, this means every tenant's policies are loaded into memory even when only one tenant's policies are needed for a given request.

Proposed Solution

Option A: Add a filter config to the GORM adapter that applies a WHERE clause during LoadPolicy, filtering on a V-column that holds tenant_id. Casbin's FilteredAdapter interface supports this.

Option B: Support a table_name template that resolves per-tenant, e.g., casbin_rule_{{tenant}}, mirroring the database.partitioned pattern.

Use Case

Multi-tenant application where each tenant (affiliate) has independent role definitions and resource permissions. The database.partitioned module already handles per-tenant data isolation via LIST partitions — authorization needs a similar model.</issue_description>

<agent_instructions>Support both options, ensure we have tests to validate</agent_instructions>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

…n B)

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI changed the title [WIP] Add tenant-scoped policy loading for GORM adapter feat: tenant-scoped policy loading for GORM adapter (filter + per-tenant table) Mar 12, 2026
@intel352 intel352 marked this pull request as ready for review March 12, 2026 13:58
Copilot AI review requested due to automatic review settings March 12, 2026 13:58
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds multi-tenant isolation support for the internal Casbin GORM adapter, plus SQLite migration robustness fixes, so each enforcer can load/save only the intended tenant’s policies.

Changes:

  • Implement tenant-scoped policy loading via a column filter (filter_field / filter_value) and expose it through Casbin’s persist.FilteredAdapter.
  • Support per-tenant policy tables via templated table_name resolved at adapter creation time using {{.Tenant}}.
  • Fix SQLite migrator behavior for table/index/column existence checks and adjust index creation to avoid SQLite’s global index-name conflicts.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
internal/gorm_adapter.go Adds filtered + per-table behavior, custom migration/index creation, and filtered adapter support.
internal/module_casbin.go Extends adapter config, resolves table_name templates, and adjusts mutation methods when using a filtered enforcer.
internal/sqlite_dialector.go Introduces an SQLite-specific migrator override for HasTable / HasIndex / HasColumn.
internal/module_casbin_storage_test.go Adds tests for tenant filter mode and per-tenant table mode, plus validation tests.
Comments suppressed due to low confidence (1)

internal/gorm_adapter.go:269

  • When tenant filtering is enabled, RemoveFilteredPolicy does not automatically add the adapter’s tenant filter condition, so a filtered enforcer could still remove policies belonging to other tenants if invoked with broad parameters. Consider always AND-ing the configured filterField=filterValue onto the delete query (or rejecting calls that don’t include it) while IsFiltered()/filter config is active.
// RemoveFilteredPolicy removes policy rules matching the given filter.
func (a *gormAdapter) RemoveFilteredPolicy(sec, ptype string, fieldIndex int, fieldValues ...string) error {
	query := a.table().Where("ptype = ?", ptype)
	fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"}
	for i, v := range fieldValues {
		if v != "" {
			col := fields[fieldIndex+i]
			query = query.Where(col+" = ?", v)
		}
	}
	return query.Delete(&casbinRule{}).Error
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +196 to +201
if field != "" && value != "" {
// field is already validated against validFilterFields (v0-v5), so
// backtick-quoting is safe and provides defence-in-depth against any
// future code path that might supply an unvalidated column name.
q = q.Where("`"+field+"` = ?", value)
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

loadWithFilter builds the WHERE clause using backtick-quoted identifiers (""+field+" = ?"). Backticks are not valid identifier quoting on PostgreSQL, so tenant filtering will fail on the postgres driver. Use GORM’s clause API / dialector quoting to build an identifier-safe equality expression without hard-coding quote characters.

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +112
if filterField != "" && !validFilterFields[filterField] {
return nil, fmt.Errorf("gorm casbin adapter: invalid filter_field %q (must be v0-v5)", filterField)
}
if err := migrateTable(db, tableName); err != nil {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

newGORMAdapter accepts filterField/filterValue independently. If only one is set (e.g., filter_field provided but filter_value empty), LoadPolicy won’t apply a filter and IsFiltered will never flip, but the config looks like tenant isolation is enabled. Consider validating that filterField and filterValue are either both set or both empty, and fail adapter creation on partial configuration.

Copilot uses AI. Check for mistakes.
Comment on lines 246 to 256
// AddPolicy adds a policy rule to the database.
func (a *gormAdapter) AddPolicy(sec, ptype string, rule []string) error {
r := lineToRule(ptype, rule)
return a.db.Create(&r).Error
return a.table().Create(&r).Error
}

// RemovePolicy removes a policy rule from the database.
func (a *gormAdapter) RemovePolicy(sec, ptype string, rule []string) error {
r := lineToRule(ptype, rule)
return a.db.Where(&r).Delete(&casbinRule{}).Error
return a.table().Where(&r).Delete(&casbinRule{}).Error
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

When tenant filtering is enabled, AddPolicy/RemovePolicy currently write/delete rows solely based on the provided rule values, without enforcing the adapter’s filter scope. A caller can accidentally insert/delete another tenant’s rows by passing a rule with a different vN value than the configured filter. To make tenant isolation reliable, consider validating that the rule’s filtered field matches filterValue (or overriding it) and returning an error when it doesn’t.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +139
func migrateTable(db *gorm.DB, tableName string) error {
if !db.Migrator().HasTable(tableName) {
if err := db.Table(tableName).AutoMigrate(&casbinRule{}); err != nil {
return err
}
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

migrateTable skips AutoMigrate whenever the table already exists, regardless of dialect. On postgres/mysql this means existing deployments won’t get schema drift corrected (missing columns, type changes, etc.), which AutoMigrate previously handled. If the intent is SQLite-only safety, consider restricting the “only migrate new tables” behavior to the SQLite dialector, or documenting that existing tables will not be auto-migrated on any DB.

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +150
return db.Exec(fmt.Sprintf(
`CREATE UNIQUE INDEX %q ON %q ("ptype","v0","v1","v2","v3","v4","v5")`,
idxName, tableName,
)).Error
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

migrateTable uses a raw CREATE UNIQUE INDEX %q ON %q ("ptype",...) statement. %q produces a Go string literal (double-quoted with backslash escapes) and the double-quote identifier quoting is not portable (notably breaks MySQL unless ANSI_QUOTES is enabled). Consider using GORM/dialector identifier quoting (e.g., via the dialector’s QuoteTo / clause builders) or a migrator-based index creation approach so the SQL is correct across sqlite/postgres/mysql.

Copilot uses AI. Check for mistakes.
@intel352
Copy link
Contributor

@copilot apply changes based on the comments in this thread

Fix the linter errors

…tion, tenant scope

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot apply changes based on the comments in this thread

Fix the linter errors

Applied all fixes from the review thread in commit 4bbfad3:

  • Identifier quoting – replaced backtick-based WHERE clause with clause.Eq{Column: clause.Column{Name: field}, Value: value} (dialect-aware, works on MySQL/PostgreSQL/SQLite). CREATE UNIQUE INDEX now uses quoteIdent(db, name) via db.Dialector.QuoteTo instead of %q.
  • LoadFilteredPolicy filter storage – stores f.Field/f.Value back onto the adapter after a successful load so SavePolicy uses the correct scope; clears them when no filter is passed.
  • Partial filter configfilter_field and filter_value must both be set or both be empty; partial config now fails at Init with a clear error.
  • Cross-tenant write preventionAddPolicy and RemovePolicy call new checkTenantScope which rejects rules whose tenant field doesn't match filterValue.
  • migrateTable skip scope – AutoMigrate is now only skipped on SQLite for already-existing tables; on PostgreSQL and MySQL it always runs so schema drift is corrected.
  • Doc commentsqliteMigrator comment updated to mention HasColumn.

@intel352
Copy link
Contributor

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot gofmt failed: https://github.com/GoCodeAlone/workflow-plugin-authz/actions/runs/23011250990/job/66822597872?pr=14

Fixed in c443766 — removed extra alignment spaces from the anonymous struct literal and extra blank lines in module_casbin_storage_test.go via gofmt -w.

@intel352 intel352 merged commit afcbce3 into main Mar 12, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: tenant-scoped policy loading for GORM adapter

3 participants