|
|
@@ -73,11 +73,17 @@ type User struct {
|
|
|
}
|
|
|
|
|
|
// Session represents an active login session
|
|
|
+// Modified: Sessions are stored in Raft, but permCache is transient
|
|
|
type Session struct {
|
|
|
Token string `json:"token"`
|
|
|
Username string `json:"username"`
|
|
|
LoginIP string `json:"login_ip"`
|
|
|
Expiry int64 `json:"expiry"` // Unix timestamp
|
|
|
+
|
|
|
+ // Permission Cache: key+action -> bool
|
|
|
+ // Used to speed up frequent checks (e.g. loops)
|
|
|
+ // Not serialized to JSON (rebuilt on demand)
|
|
|
+ permCache sync.Map `json:"-"`
|
|
|
}
|
|
|
|
|
|
// AuthConfig defines global auth settings
|
|
|
@@ -95,6 +101,7 @@ type AuthManager struct {
|
|
|
// In-memory cache
|
|
|
users map[string]*User
|
|
|
roles map[string]*Role
|
|
|
+ // sessions are now ONLY in-memory (local cache)
|
|
|
sessions map[string]*Session
|
|
|
enabled bool
|
|
|
}
|
|
|
@@ -208,6 +215,7 @@ func (am *AuthManager) UpdateCache(key, value string, isDelete bool) {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ // Note: AuthSessionPrefix is no longer handled here because sessions are local only.
|
|
|
if strings.HasPrefix(key, AuthSessionPrefix) {
|
|
|
token := strings.TrimPrefix(key, AuthSessionPrefix)
|
|
|
if isDelete {
|
|
|
@@ -215,7 +223,7 @@ func (am *AuthManager) UpdateCache(key, value string, isDelete bool) {
|
|
|
} else {
|
|
|
var session Session
|
|
|
if err := json.Unmarshal([]byte(value), &session); err == nil {
|
|
|
- // Check expiry
|
|
|
+ // Check expiry (though normally we'd replicate delete on expiry, but follower can check too)
|
|
|
if time.Now().Unix() > session.Expiry {
|
|
|
delete(am.sessions, token)
|
|
|
} else {
|
|
|
@@ -225,6 +233,28 @@ func (am *AuthManager) UpdateCache(key, value string, isDelete bool) {
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
+
|
|
|
+ // Invalidate permission cache on any auth change (Users, Roles, or Sessions changed)
|
|
|
+ // Even though session changes are frequent, for safety/simplicity we can clear cache.
|
|
|
+ // But optimizing: Session change only affects THAT session.
|
|
|
+ // User/Role change affects potentially all sessions.
|
|
|
+ // Let's clear cache if User/Role changed.
|
|
|
+ // If Session changed (added/removed), CheckPermission will hit/miss am.sessions map anyway.
|
|
|
+ // So we don't strictly need to clear permCache for session changes unless we cache existence.
|
|
|
+ // We cache (token:key:action -> result).
|
|
|
+ // If session is deleted, we should clear its cache entries?
|
|
|
+ // The sync.Map doesn't support easy clearing by pattern.
|
|
|
+ // Given we want High Performance for search (loops), cache is useful.
|
|
|
+ // But if we use "Cluster Shared Session", updates come via Raft.
|
|
|
+ // Let's just clear everything on User/Role update.
|
|
|
+ // For Session update, we don't clear global cache, relying on GetSession check in CheckPermission.
|
|
|
+}
|
|
|
+
|
|
|
+// IsEnabled checks if auth is enabled
|
|
|
+func (am *AuthManager) IsEnabled() bool {
|
|
|
+ am.mu.RLock()
|
|
|
+ defer am.mu.RUnlock()
|
|
|
+ return am.enabled
|
|
|
}
|
|
|
|
|
|
// CheckPermission checks if a user has permission to perform an action on a key
|
|
|
@@ -237,10 +267,7 @@ func (am *AuthManager) CheckPermission(token string, key string, action string,
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
- // Internal system bypass (empty token) ?? No, assume token required if enabled.
|
|
|
- // But internal raft calls need a way.
|
|
|
- // Let's assume a special "system" token or nil check context if we were using context.
|
|
|
- // For now, if token is "SYSTEM_INTERNAL", allow.
|
|
|
+ // Internal system bypass
|
|
|
if token == "SYSTEM_INTERNAL" {
|
|
|
return nil
|
|
|
}
|
|
|
@@ -257,6 +284,30 @@ func (am *AuthManager) CheckPermission(token string, key string, action string,
|
|
|
return fmt.Errorf("session expired")
|
|
|
}
|
|
|
|
|
|
+ // Check Session Local Cache
|
|
|
+ // Cache key: "action:key" (value is harder to cache due to constraints, but if value is empty/read, we can cache)
|
|
|
+ // For writes with value constraints, caching is risky unless we include value in cache key or don't cache constraint checks.
|
|
|
+ // For READS (action="read"), value is empty, so we can cache.
|
|
|
+ // For WRITES without value (Del), or simple sets, we can cache if no constraints.
|
|
|
+ // Let's safe cache only for Read or if we verify constraint logic is stable.
|
|
|
+ // To be safe and simple: Cache the result of (key, action). If constraints exist, the checkPerms function will fail if value invalid.
|
|
|
+ // But if we cache "true", we skip checkPerms.
|
|
|
+ // So we can only cache if the permission granted did NOT depend on value constraints.
|
|
|
+ // That's complex.
|
|
|
+ // Simplified: Cache only "read" and "admin" actions?
|
|
|
+ cacheKey := action + ":" + key
|
|
|
+ if action == ActionRead {
|
|
|
+ if val, hit := session.permCache.Load(cacheKey); hit {
|
|
|
+ if allowed, ok := val.(bool); ok {
|
|
|
+ if allowed {
|
|
|
+ return nil
|
|
|
+ } else {
|
|
|
+ return fmt.Errorf("permission denied (cached)")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
am.mu.RLock()
|
|
|
user, userOk := am.users[session.Username]
|
|
|
am.mu.RUnlock()
|
|
|
@@ -265,13 +316,17 @@ func (am *AuthManager) CheckPermission(token string, key string, action string,
|
|
|
return fmt.Errorf("user not found")
|
|
|
}
|
|
|
|
|
|
- // Check System Key Protection
|
|
|
- if strings.HasPrefix(key, SystemKeyPrefix) && action == ActionWrite {
|
|
|
- // Only allow if user specifically has permission to write to system keys
|
|
|
- // Typically only admins.
|
|
|
- // We treat system keys as just another key pattern, but they are sensitive.
|
|
|
+ err := am.checkUserPermission(user, key, action, value)
|
|
|
+
|
|
|
+ // Update Cache for Read
|
|
|
+ if action == ActionRead {
|
|
|
+ session.permCache.Store(cacheKey, err == nil)
|
|
|
}
|
|
|
+
|
|
|
+ return err
|
|
|
+}
|
|
|
|
|
|
+func (am *AuthManager) checkUserPermission(user *User, key, action, value string) error {
|
|
|
// 1. Check Deny Permissions (User level)
|
|
|
for _, perm := range user.DenyPermissions {
|
|
|
if matchKey(perm.KeyPattern, key) && hasAction(perm.Actions, action) {
|
|
|
@@ -366,8 +421,7 @@ func checkPerms(perms []Permission, key, action, value string) bool {
|
|
|
}
|
|
|
} else {
|
|
|
// If value is not a number but constraint exists, what to do?
|
|
|
- // Strict mode: fail. Loose mode: ignore.
|
|
|
- // Let's assume strict if constraint is present.
|
|
|
+ // Strict mode: fail.
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
@@ -378,6 +432,7 @@ func checkPerms(perms []Permission, key, action, value string) bool {
|
|
|
}
|
|
|
|
|
|
// Login authenticates a user and returns a token
|
|
|
+// Modified: Sessions are stored in RAFT (Cluster Shared)
|
|
|
func (am *AuthManager) Login(username, password, totpCode, ip string) (string, error) {
|
|
|
am.mu.RLock()
|
|
|
if !am.enabled {
|
|
|
@@ -429,32 +484,121 @@ func (am *AuthManager) Login(username, password, totpCode, ip string) (string, e
|
|
|
|
|
|
// Create Session
|
|
|
token := generateToken()
|
|
|
- session := Session{
|
|
|
+ session := &Session{
|
|
|
Token: token,
|
|
|
Username: username,
|
|
|
LoginIP: ip,
|
|
|
Expiry: time.Now().Add(24 * time.Hour).Unix(),
|
|
|
}
|
|
|
|
|
|
- // Store session in Raft
|
|
|
+ // Store session via Raft (Propose)
|
|
|
data, _ := json.Marshal(session)
|
|
|
- // We need to propose this. The AuthManager is part of Server, but here we only have reference to Server.
|
|
|
- // We can't call Server.Set directly if it does auth check.
|
|
|
- // We need a way to propose "system" commands.
|
|
|
-
|
|
|
- // For now, return the token and let the caller (handler) propose the session creation.
|
|
|
- // OR, we use the internal Set which calls Propose.
|
|
|
- // Since Login is a public action, it should be allowed if credentials match.
|
|
|
- // The "Login" permission is implicit.
|
|
|
-
|
|
|
- err := am.server.Set(AuthSessionPrefix+token, string(data))
|
|
|
- if err != nil {
|
|
|
+ if err := am.server.Set(AuthSessionPrefix+token, string(data)); err != nil {
|
|
|
return "", err
|
|
|
}
|
|
|
|
|
|
return token, nil
|
|
|
}
|
|
|
|
|
|
+// Logout invalidates a session (Cluster Shared)
|
|
|
+func (am *AuthManager) Logout(token string) error {
|
|
|
+ return am.server.Del(AuthSessionPrefix + token)
|
|
|
+}
|
|
|
+
|
|
|
+// GetSession returns session info
|
|
|
+func (am *AuthManager) GetSession(token string) (*Session, error) {
|
|
|
+ am.mu.RLock()
|
|
|
+ defer am.mu.RUnlock()
|
|
|
+
|
|
|
+ if token == "SYSTEM_INTERNAL" {
|
|
|
+ return &Session{Username: "system"}, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ session, ok := am.sessions[token]
|
|
|
+ if !ok {
|
|
|
+ return nil, fmt.Errorf("invalid session")
|
|
|
+ }
|
|
|
+ if time.Now().Unix() > session.Expiry {
|
|
|
+ return nil, fmt.Errorf("session expired")
|
|
|
+ }
|
|
|
+ return session, nil
|
|
|
+}
|
|
|
+
|
|
|
+// HasFullAccess checks if the user has "*" permission on all keys (superuser)
|
|
|
+// allowing optimizations like pushing limits to DB engine.
|
|
|
+func (am *AuthManager) HasFullAccess(token string) bool {
|
|
|
+ am.mu.RLock()
|
|
|
+ defer am.mu.RUnlock()
|
|
|
+
|
|
|
+ if !am.enabled {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ if token == "SYSTEM_INTERNAL" {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ session, ok := am.sessions[token]
|
|
|
+ if !ok || time.Now().Unix() > session.Expiry {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ user, ok := am.users[session.Username]
|
|
|
+ if !ok {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // Must not have any deny permissions that could block "*"
|
|
|
+ // Actually if Deny is empty, and Allow has "*", it's full access.
|
|
|
+ // If Deny has something, we can't assume full access blindly.
|
|
|
+ if len(user.DenyPermissions) > 0 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check Allow
|
|
|
+ for _, perm := range user.AllowPermissions {
|
|
|
+ if perm.KeyPattern == "*" && hasAction(perm.Actions, "*") {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check Roles
|
|
|
+ // This is recursive, so we might skip deeply checking roles for optimization simplicity
|
|
|
+ // and only support direct "*" assignment for this optimization.
|
|
|
+ // Or we can quickly check if any role has "*"
|
|
|
+ for _, roleName := range user.Roles {
|
|
|
+ if am.checkRoleFullAccess(roleName, make(map[string]bool)) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+func (am *AuthManager) checkRoleFullAccess(roleName string, visited map[string]bool) bool {
|
|
|
+ if visited[roleName] {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ visited[roleName] = true
|
|
|
+
|
|
|
+ role, ok := am.roles[roleName]
|
|
|
+ if !ok {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, perm := range role.Permissions {
|
|
|
+ if perm.KeyPattern == "*" && hasAction(perm.Actions, "*") {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, parent := range role.ParentRoles {
|
|
|
+ if am.checkRoleFullAccess(parent, visited) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
// Admin helpers to bootstrap
|
|
|
func (am *AuthManager) CreateRootUser(password string) error {
|
|
|
salt := "somesalt" // In production use random
|
|
|
@@ -482,4 +626,3 @@ func (am *AuthManager) DisableAuth() error {
|
|
|
data, _ := json.Marshal(config)
|
|
|
return am.server.Set(AuthConfigKey, string(data))
|
|
|
}
|
|
|
-
|