feat: better admin permissions (#5755)
* feat: add casbin admin permissions * feat: improve audit logging to associate logs with actual operators and target users * feat: enhance admin permissions and UI interactions for sensitive actions * Refactor authz RBAC and tighten channel permissions * Split channel authz field policy * Address channel authz review findings
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
casbinmodel "github.com/casbin/casbin/v2/model"
|
||||
"github.com/casbin/casbin/v2/persist"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type gormAdapter struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func newGormAdapter(db *gorm.DB) *gormAdapter {
|
||||
return &gormAdapter{db: db}
|
||||
}
|
||||
|
||||
func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error {
|
||||
var rules []model.CasbinRule
|
||||
if err := a.db.Order("id asc").Find(&rules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *gormAdapter) SavePolicy(m casbinmodel.Model) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1 = 1").Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]model.CasbinRule, 0)
|
||||
for ptype, ast := range m["p"] {
|
||||
for _, policy := range ast.Policy {
|
||||
rules = append(rules, newRule(ptype, policy))
|
||||
}
|
||||
}
|
||||
for ptype, ast := range m["g"] {
|
||||
for _, policy := range ast.Policy {
|
||||
rules = append(rules, newRule(ptype, policy))
|
||||
}
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rules).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error {
|
||||
casbinRule := newRule(ptype, rule)
|
||||
var count int64
|
||||
if err := a.ruleQuery(a.db.Model(&model.CasbinRule{}), ptype, rule).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error {
|
||||
return a.ruleQuery(a.db, ptype, rule).Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
|
||||
query := a.db.Where("ptype = ?", ptype)
|
||||
for i, value := range fieldValues {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
query = query.Where("v"+string(rune('0'+fieldIndex+i))+" = ?", value)
|
||||
}
|
||||
return query.Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func (a *gormAdapter) ruleQuery(query *gorm.DB, ptype string, rule []string) *gorm.DB {
|
||||
query = query.Where("ptype = ?", ptype)
|
||||
for idx := 0; idx < 6; idx++ {
|
||||
value := ""
|
||||
if idx < len(rule) {
|
||||
value = rule[idx]
|
||||
}
|
||||
query = query.Where("v"+string(rune('0'+idx))+" = ?", value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func newRule(ptype string, policy []string) model.CasbinRule {
|
||||
rule := model.CasbinRule{Ptype: ptype}
|
||||
values := []*string{&rule.V0, &rule.V1, &rule.V2, &rule.V3, &rule.V4, &rule.V5}
|
||||
for idx, value := range policy {
|
||||
if idx >= len(values) {
|
||||
break
|
||||
}
|
||||
*values[idx] = value
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func ruleToLine(rule model.CasbinRule) string {
|
||||
parts := []string{rule.Ptype}
|
||||
values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5}
|
||||
if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" {
|
||||
values[3] = EffectAllow
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package authz
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
|
||||
// resolveSubjectRoles returns the role keys assigned to a subject. The mapping
|
||||
// is derived from the caller's system role.
|
||||
var resolveSubjectRoles = func(userID int, systemRole int) []string {
|
||||
switch {
|
||||
case systemRole >= common.RoleRootUser:
|
||||
return []string{BuiltInRoleRoot}
|
||||
case systemRole >= common.RoleAdminUser:
|
||||
return []string{BuiltInRoleAdmin}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// managedRoleKey is the role whose baseline per-user overrides are expressed
|
||||
// relative to.
|
||||
const managedRoleKey = BuiltInRoleAdmin
|
||||
@@ -0,0 +1,229 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newAuthzTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
wasMaster := common.IsMasterNode
|
||||
common.IsMasterNode = true
|
||||
t.Cleanup(func() {
|
||||
common.IsMasterNode = wasMaster
|
||||
})
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
|
||||
require.NoError(t, Init(db))
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
// root is a superuser role and is granted everything implicitly, so only the
|
||||
// admin baseline is written as explicit policy rows.
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error)
|
||||
assert.Equal(t, int64(len(PermissionsForRole(BuiltInRoleAdmin))), count)
|
||||
|
||||
var roles []model.AuthzRole
|
||||
require.NoError(t, db.Order("sort asc").Find(&roles).Error)
|
||||
require.Len(t, roles, 2)
|
||||
assert.Equal(t, BuiltInRoleRoot, roles[0].Key)
|
||||
assert.Equal(t, BuiltInRoleAdmin, roles[1].Key)
|
||||
|
||||
assert.True(t, Can(1, common.RoleRootUser, ChannelSensitiveWrite))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelRead))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelOperate))
|
||||
assert.True(t, Can(2, common.RoleAdminUser, ChannelWrite))
|
||||
assert.False(t, Can(2, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(3, common.RoleCommonUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) {
|
||||
wasMaster := common.IsMasterNode
|
||||
common.IsMasterNode = false
|
||||
t.Cleanup(func() {
|
||||
common.IsMasterNode = wasMaster
|
||||
})
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
|
||||
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
var roleCount int64
|
||||
require.NoError(t, db.Model(&model.AuthzRole{}).Count(&roleCount).Error)
|
||||
assert.Equal(t, int64(0), roleCount)
|
||||
var policyCount int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&policyCount).Error)
|
||||
assert.Equal(t, int64(0), policyCount)
|
||||
assert.False(t, Can(2, common.RoleAdminUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, SetUserPermissions(42, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
"unknown": true,
|
||||
},
|
||||
"unknown": {
|
||||
ActionRead: true,
|
||||
},
|
||||
}))
|
||||
|
||||
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelWrite))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
},
|
||||
}, ExplicitUserPermissions(42))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionSensitiveWrite: true,
|
||||
ActionWrite: false,
|
||||
},
|
||||
}, ExplicitUserOverrides(42))
|
||||
|
||||
var userPolicyCount int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(42)).Count(&userPolicyCount).Error)
|
||||
assert.Equal(t, int64(2), userPolicyCount)
|
||||
|
||||
require.NoError(t, SetUserPermissions(42, PermissionsMap{ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: false,
|
||||
ActionSecretView: false,
|
||||
}}))
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.Equal(t, PermissionsMap{
|
||||
ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: false,
|
||||
ActionSecretView: false,
|
||||
},
|
||||
}, ExplicitUserPermissions(42))
|
||||
assert.Empty(t, ExplicitUserOverrides(42))
|
||||
}
|
||||
|
||||
func TestClearUserAuthorizationRemovesOverrides(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, SetUserPermissions(90, PermissionsMap{ResourceChannel: {
|
||||
ActionWrite: false,
|
||||
ActionSensitiveWrite: true,
|
||||
}}))
|
||||
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(90, common.RoleAdminUser, ChannelWrite))
|
||||
|
||||
require.NoError(t, ClearUserAuthorization(90))
|
||||
|
||||
assert.Empty(t, ExplicitUserOverrides(90))
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelRead))
|
||||
assert.True(t, Can(90, common.RoleAdminUser, ChannelWrite))
|
||||
assert.False(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
assert.False(t, Can(90, common.RoleCommonUser, ChannelRead))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsInTxDoesNotMutateEnforcerBeforeReload(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
require.NoError(t, db.Transaction(func(tx *gorm.DB) error {
|
||||
return SetUserPermissionsInTx(tx, 42, PermissionsMap{ResourceChannel: {
|
||||
ActionRead: true,
|
||||
ActionOperate: true,
|
||||
ActionWrite: true,
|
||||
ActionSensitiveWrite: true,
|
||||
ActionSecretView: false,
|
||||
}})
|
||||
}))
|
||||
|
||||
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
require.NoError(t, ReloadPolicy())
|
||||
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
}
|
||||
|
||||
func TestSetUserPermissionsInTxRollbackLeavesNoPolicy(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
tx := db.Begin()
|
||||
require.NoError(t, tx.Error)
|
||||
require.NoError(t, SetUserPermissionsInTx(tx, 43, PermissionsMap{ResourceChannel: {
|
||||
ActionSensitiveWrite: true,
|
||||
}}))
|
||||
require.NoError(t, tx.Rollback().Error)
|
||||
require.NoError(t, ReloadPolicy())
|
||||
|
||||
assert.False(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite))
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(43)).Count(&count).Error)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestAdapterAddPolicyIsIdempotent(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
adapter := newGormAdapter(db)
|
||||
rule := []string{UserSubject(55), ResourceChannel, ActionSensitiveWrite, EffectAllow}
|
||||
|
||||
require.NoError(t, adapter.AddPolicy("p", "p", rule))
|
||||
require.NoError(t, adapter.AddPolicy("p", "p", rule))
|
||||
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.CasbinRule{}).Where(
|
||||
"ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ?",
|
||||
"p",
|
||||
UserSubject(55),
|
||||
ResourceChannel,
|
||||
ActionSensitiveWrite,
|
||||
EffectAllow,
|
||||
).Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestCapabilitiesUseCatalogShape(t *testing.T) {
|
||||
db := newAuthzTestDB(t)
|
||||
require.NoError(t, Init(db))
|
||||
|
||||
capabilities := Capabilities(7, common.RoleAdminUser)
|
||||
|
||||
assert.True(t, capabilities[ResourceChannel][ActionRead])
|
||||
assert.True(t, capabilities[ResourceChannel][ActionOperate])
|
||||
assert.True(t, capabilities[ResourceChannel][ActionWrite])
|
||||
assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite])
|
||||
assert.False(t, capabilities[ResourceChannel][ActionSecretView])
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/casbin/casbin/v2"
|
||||
casbinmodel "github.com/casbin/casbin/v2/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
enforcerMu sync.RWMutex
|
||||
enforcer *casbin.SyncedEnforcer
|
||||
)
|
||||
|
||||
const modelText = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act, eft
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow"
|
||||
`
|
||||
|
||||
func Init(db *gorm.DB) error {
|
||||
if common.IsMasterNode {
|
||||
if err := seedBuiltInRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resetBuiltInRolePolicies(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
m, err := casbinmodel.NewModelFromString(modelText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.EnableAutoSave(true)
|
||||
|
||||
enforcerMu.Lock()
|
||||
enforcer = e
|
||||
enforcerMu.Unlock()
|
||||
|
||||
if !common.IsMasterNode {
|
||||
return nil
|
||||
}
|
||||
return seedDefaultPolicies()
|
||||
}
|
||||
|
||||
func currentEnforcer() *casbin.SyncedEnforcer {
|
||||
enforcerMu.RLock()
|
||||
defer enforcerMu.RUnlock()
|
||||
return enforcer
|
||||
}
|
||||
|
||||
func ReloadPolicy() error {
|
||||
enforcerMu.Lock()
|
||||
defer enforcerMu.Unlock()
|
||||
if enforcer == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
return enforcer.LoadPolicy()
|
||||
}
|
||||
|
||||
// StartPolicySync periodically reloads the authorization policy from the database.
|
||||
// The enforcer keeps an in-memory snapshot, and permission changes are written
|
||||
// straight to the DB (see SetUserPermissionsInTx) with only the local node's
|
||||
// snapshot refreshed afterwards. Without this loop other instances in a
|
||||
// multi-node deployment would keep serving stale permissions (including not
|
||||
// honoring a revoked grant) until restart. Mirrors model.SyncOptions polling.
|
||||
func StartPolicySync(frequency int) {
|
||||
if frequency <= 0 {
|
||||
return
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Duration(frequency) * time.Second)
|
||||
if err := ReloadPolicy(); err != nil {
|
||||
common.SysError("failed to reload authz policy: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/casbin/casbin/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type overridePolicy struct {
|
||||
Resource string
|
||||
Action string
|
||||
Effect string
|
||||
}
|
||||
|
||||
func SetUserPermissions(userID int, permissions PermissionsMap) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for resource, actions := range permissions {
|
||||
if !isKnownResource(resource) {
|
||||
continue
|
||||
}
|
||||
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range userOverridePolicies(e, resource, actions) {
|
||||
if _, err := e.AddPolicy(UserSubject(userID), policy.Resource, policy.Action, policy.Effect); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetUserPermissionsInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for resource, actions := range permissions {
|
||||
if !isKnownResource(resource) {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource).Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
policies := userOverridePolicies(e, resource, actions)
|
||||
if len(policies) == 0 {
|
||||
continue
|
||||
}
|
||||
rules := make([]model.CasbinRule, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
rules = append(rules, newRule("p", []string{UserSubject(userID), policy.Resource, policy.Action, policy.Effect}))
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserPermissions(userID int) error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for _, resource := range registry {
|
||||
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error {
|
||||
for _, resource := range registry {
|
||||
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource.Resource).Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserAuthorization(userID int) error {
|
||||
return ClearUserPermissions(userID)
|
||||
}
|
||||
|
||||
func ClearUserAuthorizationInTx(tx *gorm.DB, userID int) error {
|
||||
return ClearUserPermissionsInTx(tx, userID)
|
||||
}
|
||||
|
||||
// ExplicitUserPermissions returns the effective permission matrix for the
|
||||
// managed role plus any per-user overrides.
|
||||
func ExplicitUserPermissions(userID int) PermissionsMap {
|
||||
return Capabilities(userID, common.RoleAdminUser)
|
||||
}
|
||||
|
||||
// ExplicitUserOverrides returns only the per-user override entries.
|
||||
func ExplicitUserOverrides(userID int) PermissionsMap {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return PermissionsMap{}
|
||||
}
|
||||
|
||||
result := PermissionsMap{}
|
||||
for _, resource := range registry {
|
||||
policies, err := e.GetFilteredPolicy(0, UserSubject(userID), resource.Resource)
|
||||
if err != nil {
|
||||
return PermissionsMap{}
|
||||
}
|
||||
actions := make(map[string]bool, len(policies))
|
||||
for _, policy := range policies {
|
||||
if len(policy) >= 3 && isKnownPermission(Permission{Resource: policy[1], Action: policy[2]}) {
|
||||
effect := policyEffect(policy)
|
||||
if effect == EffectAllow || effect == EffectDeny {
|
||||
actions[policy[2]] = effect == EffectAllow
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
result[resource.Resource] = actions
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// userOverridePolicies returns the override entries that differ from the managed
|
||||
// role baseline; entries matching the baseline are omitted.
|
||||
func userOverridePolicies(e *casbin.SyncedEnforcer, resource string, actions map[string]bool) []overridePolicy {
|
||||
overrides := make([]overridePolicy, 0, len(actions))
|
||||
for _, action := range catalogActions(resource) {
|
||||
desired, ok := actions[action.Action]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
permission := Permission{Resource: resource, Action: action.Action}
|
||||
if desired == roleBaselineAllows(e, managedRoleKey, permission) {
|
||||
continue
|
||||
}
|
||||
effect := EffectDeny
|
||||
if desired {
|
||||
effect = EffectAllow
|
||||
}
|
||||
overrides = append(overrides, overridePolicy{
|
||||
Resource: resource,
|
||||
Action: action.Action,
|
||||
Effect: effect,
|
||||
})
|
||||
}
|
||||
sort.Slice(overrides, func(i, j int) bool {
|
||||
return overrides[i].Action < overrides[j].Action
|
||||
})
|
||||
return overrides
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package authz
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Permission identifies a single action on a resource.
|
||||
type Permission struct {
|
||||
Resource string
|
||||
Action string
|
||||
}
|
||||
|
||||
// PermissionsMap is a resource -> action -> allowed lookup.
|
||||
type PermissionsMap map[string]map[string]bool
|
||||
|
||||
const (
|
||||
EffectAllow = "allow"
|
||||
EffectDeny = "deny"
|
||||
)
|
||||
|
||||
// UserSubject is the casbin subject string for a single user.
|
||||
func UserSubject(userID int) string {
|
||||
return "user:" + strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
// RoleSubject is the casbin subject string for a role.
|
||||
func RoleSubject(roleKey string) string {
|
||||
return "role:" + roleKey
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package authz
|
||||
|
||||
// ActionDefinition describes a single action exposed by a resource. DefaultRoles
|
||||
// lists the role keys that receive this action as part of their baseline grants.
|
||||
type ActionDefinition struct {
|
||||
Action string `json:"action"`
|
||||
LabelKey string `json:"label_key"`
|
||||
DescriptionKey string `json:"description_key"`
|
||||
DefaultRoles []string `json:"-"`
|
||||
}
|
||||
|
||||
// ResourceDefinition describes a resource and the actions it exposes.
|
||||
type ResourceDefinition struct {
|
||||
Resource string `json:"resource"`
|
||||
LabelKey string `json:"label_key"`
|
||||
Actions []ActionDefinition `json:"actions"`
|
||||
}
|
||||
|
||||
var registry []ResourceDefinition
|
||||
|
||||
// RegisterResource adds a resource definition to the permission registry.
|
||||
func RegisterResource(resource ResourceDefinition) {
|
||||
registry = append(registry, resource)
|
||||
}
|
||||
|
||||
// Catalog returns a copy of the registered resource definitions.
|
||||
func Catalog() []ResourceDefinition {
|
||||
result := make([]ResourceDefinition, 0, len(registry))
|
||||
for _, resource := range registry {
|
||||
result = append(result, ResourceDefinition{
|
||||
Resource: resource.Resource,
|
||||
LabelKey: resource.LabelKey,
|
||||
Actions: append([]ActionDefinition(nil), resource.Actions...),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AllPermissions returns every registered permission.
|
||||
func AllPermissions() []Permission {
|
||||
permissions := make([]Permission, 0)
|
||||
for _, resource := range registry {
|
||||
for _, action := range resource.Actions {
|
||||
permissions = append(permissions, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
// PermissionsForRole returns the permissions whose DefaultRoles include roleKey.
|
||||
func PermissionsForRole(roleKey string) []Permission {
|
||||
permissions := make([]Permission, 0)
|
||||
for _, resource := range registry {
|
||||
for _, action := range resource.Actions {
|
||||
if actionHasRole(action, roleKey) {
|
||||
permissions = append(permissions, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
func actionHasRole(action ActionDefinition, roleKey string) bool {
|
||||
for _, r := range action.DefaultRoles {
|
||||
if r == roleKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isKnownResource(resource string) bool {
|
||||
for _, known := range registry {
|
||||
if known.Resource == resource {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func catalogActions(resource string) []ActionDefinition {
|
||||
for _, known := range registry {
|
||||
if known.Resource == resource {
|
||||
return known.Actions
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKnownPermission(permission Permission) bool {
|
||||
for _, resource := range registry {
|
||||
if resource.Resource != permission.Resource {
|
||||
continue
|
||||
}
|
||||
for _, action := range resource.Actions {
|
||||
if action.Action == permission.Action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package authz
|
||||
|
||||
import "github.com/casbin/casbin/v2"
|
||||
|
||||
// Can reports whether the subject may perform the permission. A superuser role
|
||||
// short-circuits to allow. Otherwise a per-user override wins, then the union of
|
||||
// the subject's role baselines applies.
|
||||
func Can(userID int, systemRole int, permission Permission) bool {
|
||||
roles := resolveSubjectRoles(userID, systemRole)
|
||||
if len(roles) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, role := range roles {
|
||||
if isSuperuserRole(role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isKnownPermission(permission) {
|
||||
return false
|
||||
}
|
||||
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok {
|
||||
return effect == EffectAllow
|
||||
}
|
||||
for _, role := range roles {
|
||||
if roleBaselineAllows(e, role, permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Capabilities returns the full resource/action matrix the subject is allowed.
|
||||
func Capabilities(userID int, systemRole int) PermissionsMap {
|
||||
result := make(PermissionsMap, len(registry))
|
||||
for _, resource := range registry {
|
||||
actions := make(map[string]bool, len(resource.Actions))
|
||||
for _, action := range resource.Actions {
|
||||
actions[action.Action] = Can(userID, systemRole, Permission{
|
||||
Resource: resource.Resource,
|
||||
Action: action.Action,
|
||||
})
|
||||
}
|
||||
result[resource.Resource] = actions
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roleBaselineAllows(e *casbin.SyncedEnforcer, roleKey string, permission Permission) bool {
|
||||
effect, ok := explicitSubjectEffect(e, RoleSubject(roleKey), permission)
|
||||
return ok && effect == EffectAllow
|
||||
}
|
||||
|
||||
func explicitSubjectEffect(e *casbin.SyncedEnforcer, subject string, permission Permission) (string, bool) {
|
||||
policies, err := e.GetFilteredPolicy(0, subject, permission.Resource, permission.Action)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
hasAllow := false
|
||||
for _, policy := range policies {
|
||||
switch policyEffect(policy) {
|
||||
case EffectDeny:
|
||||
return EffectDeny, true
|
||||
case EffectAllow:
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
if hasAllow {
|
||||
return EffectAllow, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func policyEffect(policy []string) string {
|
||||
if len(policy) < 4 || policy[3] == "" {
|
||||
return EffectAllow
|
||||
}
|
||||
return policy[3]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package authz
|
||||
|
||||
const (
|
||||
ResourceChannel = "channel"
|
||||
|
||||
ActionRead = "read"
|
||||
ActionOperate = "operate"
|
||||
ActionWrite = "write"
|
||||
ActionSensitiveWrite = "sensitive_write"
|
||||
ActionSecretView = "secret_view"
|
||||
)
|
||||
|
||||
var (
|
||||
ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead}
|
||||
ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate}
|
||||
ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite}
|
||||
ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite}
|
||||
ChannelSecretView = Permission{Resource: ResourceChannel, Action: ActionSecretView}
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterResource(ResourceDefinition{
|
||||
Resource: ResourceChannel,
|
||||
LabelKey: "Channel Management",
|
||||
Actions: []ActionDefinition{
|
||||
{
|
||||
Action: ActionRead,
|
||||
LabelKey: "Read channels",
|
||||
DescriptionKey: "View channel lists and details without secrets.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionOperate,
|
||||
LabelKey: "Operate channels",
|
||||
DescriptionKey: "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionWrite,
|
||||
LabelKey: "Edit channel routing",
|
||||
DescriptionKey: "Edit non-sensitive settings such as models, groups, and routing rules.",
|
||||
DefaultRoles: []string{BuiltInRoleAdmin},
|
||||
},
|
||||
{
|
||||
Action: ActionSensitiveWrite,
|
||||
LabelKey: "Edit sensitive channel settings",
|
||||
DescriptionKey: "Create channels or edit keys, base URLs, and overrides.",
|
||||
},
|
||||
{
|
||||
Action: ActionSecretView,
|
||||
LabelKey: "View channel secrets",
|
||||
DescriptionKey: "Reserved for viewing complete channel keys after secure verification.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package authz
|
||||
|
||||
const (
|
||||
BuiltInRoleRoot = "root"
|
||||
BuiltInRoleAdmin = "admin"
|
||||
)
|
||||
|
||||
// RoleSpec describes a role. A superuser role is allowed every permission
|
||||
// without an explicit policy entry.
|
||||
type RoleSpec struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
BuiltIn bool
|
||||
Superuser bool
|
||||
Sort int
|
||||
}
|
||||
|
||||
var builtInRoles = []RoleSpec{
|
||||
{
|
||||
Key: BuiltInRoleRoot,
|
||||
Name: "Root",
|
||||
Description: "Built-in root authorization role",
|
||||
BuiltIn: true,
|
||||
Superuser: true,
|
||||
Sort: 0,
|
||||
},
|
||||
{
|
||||
Key: BuiltInRoleAdmin,
|
||||
Name: "Admin",
|
||||
Description: "Built-in admin authorization role",
|
||||
BuiltIn: true,
|
||||
Superuser: false,
|
||||
Sort: 10,
|
||||
},
|
||||
}
|
||||
|
||||
// RoleDescriptor exposes a role together with its baseline grant matrix.
|
||||
type RoleDescriptor struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
BuiltIn bool `json:"built_in"`
|
||||
Superuser bool `json:"superuser"`
|
||||
Grants PermissionsMap `json:"grants"`
|
||||
}
|
||||
|
||||
// Roles returns the role descriptors with their baseline grants.
|
||||
func Roles() []RoleDescriptor {
|
||||
result := make([]RoleDescriptor, 0, len(builtInRoles))
|
||||
for _, spec := range builtInRoles {
|
||||
result = append(result, RoleDescriptor{
|
||||
Key: spec.Key,
|
||||
Name: spec.Name,
|
||||
BuiltIn: spec.BuiltIn,
|
||||
Superuser: spec.Superuser,
|
||||
Grants: roleGrants(spec),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roleGrants(spec RoleSpec) PermissionsMap {
|
||||
grants := make(PermissionsMap, len(registry))
|
||||
for _, resource := range registry {
|
||||
actions := make(map[string]bool, len(resource.Actions))
|
||||
for _, action := range resource.Actions {
|
||||
actions[action.Action] = spec.Superuser || actionHasRole(action, spec.Key)
|
||||
}
|
||||
grants[resource.Resource] = actions
|
||||
}
|
||||
return grants
|
||||
}
|
||||
|
||||
func roleSpec(roleKey string) (RoleSpec, bool) {
|
||||
for _, spec := range builtInRoles {
|
||||
if spec.Key == roleKey {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return RoleSpec{}, false
|
||||
}
|
||||
|
||||
func isSuperuserRole(roleKey string) bool {
|
||||
spec, ok := roleSpec(roleKey)
|
||||
return ok && spec.Superuser
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func seedBuiltInRoles(db *gorm.DB) error {
|
||||
for _, spec := range builtInRoles {
|
||||
role := model.AuthzRole{
|
||||
Key: spec.Key,
|
||||
Name: spec.Name,
|
||||
Description: spec.Description,
|
||||
BuiltIn: spec.BuiltIn,
|
||||
Enabled: true,
|
||||
Sort: spec.Sort,
|
||||
}
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"description",
|
||||
"built_in",
|
||||
"enabled",
|
||||
"sort",
|
||||
}),
|
||||
}).Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetBuiltInRolePolicies(db *gorm.DB) error {
|
||||
subjects := make([]string, 0, len(builtInRoles))
|
||||
for _, spec := range builtInRoles {
|
||||
subjects = append(subjects, RoleSubject(spec.Key))
|
||||
}
|
||||
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error
|
||||
}
|
||||
|
||||
func seedDefaultPolicies() error {
|
||||
e := currentEnforcer()
|
||||
if e == nil {
|
||||
return fmt.Errorf("authz enforcer is not initialized")
|
||||
}
|
||||
|
||||
for _, spec := range builtInRoles {
|
||||
if spec.Superuser {
|
||||
continue
|
||||
}
|
||||
for _, permission := range PermissionsForRole(spec.Key) {
|
||||
if _, err := e.AddPolicy(RoleSubject(spec.Key), permission.Resource, permission.Action, EffectAllow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user