Files
pulse/pkg/auth/rbac.go
T
2026-08-09 13:36:07 +01:00

273 lines
9.1 KiB
Go

package auth
import (
"context"
"strings"
"sync"
"time"
)
// Permission defines an access rule for an action on a resource.
type Permission struct {
Action string `json:"action"` // read, write, delete, admin
Resource string `json:"resource"` // nodes, nodes:pve1, settings, *
Effect string `json:"effect,omitempty"` // "allow" (default) or "deny"
Conditions map[string]string `json:"conditions,omitempty"` // ABAC conditions, e.g., {"tag": "production"}
}
// EffectAllow and EffectDeny are the valid values for Permission.Effect
const (
EffectAllow = "allow"
EffectDeny = "deny"
)
// GetEffect returns the effect, defaulting to "allow" if empty
func (p Permission) GetEffect() string {
if p.Effect == "" {
return EffectAllow
}
return p.Effect
}
// Role represents a collection of permissions.
type Role struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ParentID string `json:"parentId,omitempty"` // For role inheritance
Permissions []Permission `json:"permissions"`
IsBuiltIn bool `json:"isBuiltIn"`
Priority int `json:"priority,omitempty"` // For conflict resolution (higher = more priority)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// UserIdentityMetadata is mutable, presentation-only identity information for
// a durable RBAC principal. Username remains the authorization key.
type UserIdentityMetadata struct {
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
ProviderType string `json:"providerType,omitempty"`
ProviderID string `json:"providerId,omitempty"`
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
}
// UserRoleAssignment maps a durable user principal to roles and optional
// human-readable identity metadata captured from the latest SSO login.
type UserRoleAssignment struct {
Username string `json:"username"`
RoleIDs []string `json:"roleIds"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
ProviderType string `json:"providerType,omitempty"`
ProviderID string `json:"providerId,omitempty"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
const (
maxIdentityDisplayNameRunes = 256
maxIdentityEmailRunes = 320
maxIdentityProviderRunes = 128
)
// NormalizeUserIdentityMetadata bounds mutable IdP claims before persistence.
// It does not turn any display field into an authorization identity.
func NormalizeUserIdentityMetadata(metadata UserIdentityMetadata) UserIdentityMetadata {
metadata.DisplayName = truncateIdentityText(metadata.DisplayName, maxIdentityDisplayNameRunes)
metadata.Email = truncateIdentityText(metadata.Email, maxIdentityEmailRunes)
metadata.ProviderType = strings.ToLower(truncateIdentityText(metadata.ProviderType, maxIdentityProviderRunes))
metadata.ProviderID = truncateIdentityText(metadata.ProviderID, maxIdentityProviderRunes)
return metadata
}
func truncateIdentityText(value string, maxRunes int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) > maxRunes {
return string(runes[:maxRunes])
}
return value
}
// RBACChangeLog represents an audit entry for RBAC changes.
type RBACChangeLog struct {
ID string `json:"id"`
Action string `json:"action"` // role_created, role_updated, role_deleted, user_assigned, etc.
EntityType string `json:"entityType"` // role, assignment
EntityID string `json:"entityId"` // Role ID or username
OldValue string `json:"oldValue,omitempty"` // JSON of previous state
NewValue string `json:"newValue,omitempty"` // JSON of new state
User string `json:"user,omitempty"` // Who made the change
Timestamp time.Time `json:"timestamp"`
}
// RBAC change action constants
const (
ActionRoleCreated = "role_created"
ActionRoleUpdated = "role_updated"
ActionRoleDeleted = "role_deleted"
ActionUserAssigned = "user_assigned"
ActionUserUnassigned = "user_unassigned"
ActionUserRolesUpdate = "user_roles_updated"
)
// Built-in Role IDs
const (
RoleAdmin = "admin"
RoleOperator = "operator"
RoleViewer = "viewer"
RoleAuditor = "auditor"
RoleCompliance = "compliance" // Alias for Auditor
)
// Manager defines the interface for managing RBAC data.
// This is typically implemented by the enterprise RBAC store.
type Manager interface {
// Role management
GetRoles() []Role
GetRole(id string) (Role, bool)
SaveRole(role Role) error
DeleteRole(id string) error
// Assignment management
GetUserAssignments() []UserRoleAssignment
GetUserAssignment(username string) (UserRoleAssignment, bool)
AssignRole(username string, roleID string) error
UpdateUserRoles(username string, roleIDs []string) error
RemoveRole(username string, roleID string) error
// Effective permissions
GetUserPermissions(username string) []Permission
}
// ExtendedManager extends Manager with advanced RBAC features.
// This is implemented by the SQLite-backed manager for Pro features.
type ExtendedManager interface {
Manager
// Role inheritance
GetRoleWithInheritance(id string) (Role, []Permission, bool) // Returns role and all inherited permissions
GetRolesWithInheritance(username string) []Role // Returns user's roles with inheritance chain
// Change log
GetChangeLogs(limit int, offset int) []RBACChangeLog
GetChangeLogsForEntity(entityType, entityID string) []RBACChangeLog
// Context-aware operations (for audit trail)
SaveRoleWithContext(role Role, username string) error
DeleteRoleWithContext(id string, username string) error
UpdateUserRolesWithContext(username string, roleIDs []string, byUser string) error
}
// ErrorAwareManager exposes storage failures that the legacy Manager interface
// cannot represent. HTTP handlers use this interface when available so a
// damaged or unavailable RBAC store is never presented as an empty data set.
type ErrorAwareManager interface {
GetRolesWithError() ([]Role, error)
GetUserAssignmentsWithError() ([]UserRoleAssignment, error)
GetUserAssignmentWithError(username string) (UserRoleAssignment, bool, error)
GetUserPermissionsWithError(username string) ([]Permission, error)
}
// AssignmentMigrator atomically moves a role assignment from a legacy
// identity alias to its canonical principal.
type AssignmentMigrator interface {
MigrateUserAssignment(fromUsername, toUsername string) error
}
// UserIdentityManager persists mutable display metadata separately from the
// durable principal used for authorization.
type UserIdentityManager interface {
UpsertUserIdentity(username string, metadata UserIdentityMetadata) error
}
// UserDeprovisioner removes a known RBAC identity and all of its assignments.
type UserDeprovisioner interface {
DeleteUser(username string) (bool, error)
}
// ContextualUserDeprovisioner adds the acting principal to the RBAC changelog.
type ContextualUserDeprovisioner interface {
DeleteUserWithContext(username, byUser string) (bool, error)
}
var (
globalManager Manager
managerMu sync.RWMutex
)
// SetManager sets the global RBAC manager instance.
// This should be called during application initialization.
func SetManager(m Manager) {
managerMu.Lock()
defer managerMu.Unlock()
globalManager = m
}
// GetManager returns the global RBAC manager instance.
func GetManager() Manager {
managerMu.RLock()
defer managerMu.RUnlock()
return globalManager
}
// Requirement helpers
func HasPermission(ctx context.Context, action, resource string) bool {
authorizer := GetAuthorizer()
allowed, _ := authorizer.Authorize(ctx, action, resource)
return allowed
}
// GetExtendedManager returns the global manager as ExtendedManager if it implements the interface.
func GetExtendedManager() ExtendedManager {
managerMu.RLock()
defer managerMu.RUnlock()
if em, ok := globalManager.(ExtendedManager); ok {
return em
}
return nil
}
// MatchesResource checks if a permission's resource pattern matches a requested resource.
// Supports:
// - Exact match: "nodes" matches "nodes"
// - Specific ID: "nodes:pve1" matches "nodes:pve1"
// - Wildcard: "nodes:*" matches "nodes:pve1"
// - Global wildcard: "*" matches any resource
func MatchesResource(pattern, resource string) bool {
// Global wildcard matches everything
if pattern == "*" {
return true
}
// Exact match
if pattern == resource {
return true
}
// Check for wildcard pattern (e.g., "nodes:*")
if len(pattern) > 2 && pattern[len(pattern)-2:] == ":*" {
prefix := pattern[:len(pattern)-2]
// Match if resource starts with the prefix
if resource == prefix {
return true
}
// Match if resource has the prefix followed by a colon
if len(resource) > len(prefix) && resource[:len(prefix)+1] == prefix+":" {
return true
}
}
return false
}
// MatchesAction checks if a permission's action matches a requested action.
// "admin" action matches any action.
func MatchesAction(permAction, requestedAction string) bool {
if permAction == "admin" {
return true
}
return permAction == requestedAction
}