/* RBAC — Role-Based Access Control for Volt. Defines roles with granular permissions, assigns users/groups to roles, and enforces access control on all CLI/API operations. Roles are stored as YAML in /etc/volt/rbac/. The system ships with four built-in roles (admin, operator, deployer, viewer) and supports custom roles. Enforcement: Commands call rbac.Require(user, permission) before executing. The user identity comes from: 1. $VOLT_USER environment variable 2. OS user (via os/user.Current()) 3. SSO token (future) Permission model is action-based: - "containers.create", "containers.delete", "containers.start", etc. - "deploy.rolling", "deploy.canary", "deploy.rollback" - "config.read", "config.write" - "admin.*" (wildcard for full access) Copyright (c) Armored Gates LLC. All rights reserved. */ package rbac import ( "fmt" "os" "os/user" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" ) // ── Constants ──────────────────────────────────────────────────────────────── const ( // DefaultRBACDir is where role and binding files are stored. DefaultRBACDir = "/etc/volt/rbac" // RolesFile stores role definitions. RolesFile = "roles.yaml" // BindingsFile stores user/group → role mappings. BindingsFile = "bindings.yaml" ) // ── Built-in Roles ─────────────────────────────────────────────────────────── // Role defines a named set of permissions. type Role struct { Name string `yaml:"name" json:"name"` Description string `yaml:"description" json:"description"` Permissions []string `yaml:"permissions" json:"permissions"` BuiltIn bool `yaml:"builtin,omitempty" json:"builtin,omitempty"` } // Binding maps a user or group to a role. type Binding struct { Subject string `yaml:"subject" json:"subject"` // username or group:name SubjectType string `yaml:"subject_type" json:"subject_type"` // "user" or "group" Role string `yaml:"role" json:"role"` } // RBACConfig holds the full RBAC state. type RBACConfig struct { Roles []Role `yaml:"roles" json:"roles"` Bindings []Binding `yaml:"bindings" json:"bindings"` } // ── Default Built-in Roles ─────────────────────────────────────────────────── var defaultRoles = []Role{ { Name: "admin", Description: "Full access to all operations", Permissions: []string{"*"}, BuiltIn: true, }, { Name: "operator", Description: "Manage containers, services, deployments, and view config", Permissions: []string{ "containers.*", "vms.*", "services.*", "deploy.*", "compose.*", "logs.read", "events.read", "top.read", "config.read", "security.audit", "health.*", "network.read", "volumes.*", "images.*", }, BuiltIn: true, }, { Name: "deployer", Description: "Deploy, restart, and view logs — no create/delete", Permissions: []string{ "deploy.*", "containers.start", "containers.stop", "containers.restart", "containers.list", "containers.inspect", "containers.logs", "services.start", "services.stop", "services.restart", "services.status", "logs.read", "events.read", "health.read", }, BuiltIn: true, }, { Name: "viewer", Description: "Read-only access to all resources", Permissions: []string{ "containers.list", "containers.inspect", "containers.logs", "vms.list", "vms.inspect", "services.list", "services.status", "deploy.status", "deploy.history", "logs.read", "events.read", "top.read", "config.read", "security.audit", "health.read", "network.read", "volumes.list", "images.list", }, BuiltIn: true, }, } // ── Store ──────────────────────────────────────────────────────────────────── // Store manages RBAC configuration on disk. type Store struct { dir string mu sync.RWMutex } // NewStore creates an RBAC store at the given directory. func NewStore(dir string) *Store { if dir == "" { dir = DefaultRBACDir } return &Store{dir: dir} } // Dir returns the RBAC directory path. func (s *Store) Dir() string { return s.dir } // ── Role Operations ────────────────────────────────────────────────────────── // LoadRoles reads role definitions from disk, merging with built-in defaults. func (s *Store) LoadRoles() ([]Role, error) { s.mu.RLock() defer s.mu.RUnlock() roles := make([]Role, len(defaultRoles)) copy(roles, defaultRoles) path := filepath.Join(s.dir, RolesFile) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return roles, nil // Return defaults only } return nil, fmt.Errorf("rbac: read roles: %w", err) } var custom struct { Roles []Role `yaml:"roles"` } if err := yaml.Unmarshal(data, &custom); err != nil { return nil, fmt.Errorf("rbac: parse roles: %w", err) } // Merge custom roles (don't override built-ins) builtinNames := make(map[string]bool) for _, r := range defaultRoles { builtinNames[r.Name] = true } for _, r := range custom.Roles { if builtinNames[r.Name] { continue // Skip attempts to redefine built-in roles } roles = append(roles, r) } return roles, nil } // GetRole returns a role by name. func (s *Store) GetRole(name string) (*Role, error) { roles, err := s.LoadRoles() if err != nil { return nil, err } for _, r := range roles { if r.Name == name { return &r, nil } } return nil, fmt.Errorf("rbac: role %q not found", name) } // CreateRole adds a new custom role. func (s *Store) CreateRole(role Role) error { s.mu.Lock() defer s.mu.Unlock() // Validate name if role.Name == "" { return fmt.Errorf("rbac: role name is required") } for _, r := range defaultRoles { if r.Name == role.Name { return fmt.Errorf("rbac: cannot redefine built-in role %q", role.Name) } } // Load existing custom roles path := filepath.Join(s.dir, RolesFile) var config struct { Roles []Role `yaml:"roles"` } data, err := os.ReadFile(path) if err == nil { yaml.Unmarshal(data, &config) } // Check for duplicate for _, r := range config.Roles { if r.Name == role.Name { return fmt.Errorf("rbac: role %q already exists", role.Name) } } config.Roles = append(config.Roles, role) return s.writeRoles(config.Roles) } // DeleteRole removes a custom role (built-in roles cannot be deleted). func (s *Store) DeleteRole(name string) error { s.mu.Lock() defer s.mu.Unlock() for _, r := range defaultRoles { if r.Name == name { return fmt.Errorf("rbac: cannot delete built-in role %q", name) } } path := filepath.Join(s.dir, RolesFile) var config struct { Roles []Role `yaml:"roles"` } data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("rbac: role %q not found", name) } yaml.Unmarshal(data, &config) found := false filtered := make([]Role, 0, len(config.Roles)) for _, r := range config.Roles { if r.Name == name { found = true continue } filtered = append(filtered, r) } if !found { return fmt.Errorf("rbac: role %q not found", name) } return s.writeRoles(filtered) } func (s *Store) writeRoles(roles []Role) error { if err := os.MkdirAll(s.dir, 0750); err != nil { return fmt.Errorf("rbac: create dir: %w", err) } config := struct { Roles []Role `yaml:"roles"` }{Roles: roles} data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("rbac: marshal roles: %w", err) } path := filepath.Join(s.dir, RolesFile) return atomicWrite(path, data) } // ── Binding Operations ─────────────────────────────────────────────────────── // LoadBindings reads user/group → role bindings from disk. func (s *Store) LoadBindings() ([]Binding, error) { s.mu.RLock() defer s.mu.RUnlock() path := filepath.Join(s.dir, BindingsFile) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("rbac: read bindings: %w", err) } var config struct { Bindings []Binding `yaml:"bindings"` } if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("rbac: parse bindings: %w", err) } return config.Bindings, nil } // AssignRole binds a user or group to a role. func (s *Store) AssignRole(subject, subjectType, roleName string) error { // Verify role exists if _, err := s.GetRole(roleName); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() bindings := s.loadBindingsUnsafe() // Check for duplicate for _, b := range bindings { if b.Subject == subject && b.SubjectType == subjectType && b.Role == roleName { return fmt.Errorf("rbac: %s %q is already assigned role %q", subjectType, subject, roleName) } } bindings = append(bindings, Binding{ Subject: subject, SubjectType: subjectType, Role: roleName, }) return s.writeBindings(bindings) } // RevokeRole removes a user/group → role binding. func (s *Store) RevokeRole(subject, subjectType, roleName string) error { s.mu.Lock() defer s.mu.Unlock() bindings := s.loadBindingsUnsafe() found := false filtered := make([]Binding, 0, len(bindings)) for _, b := range bindings { if b.Subject == subject && b.SubjectType == subjectType && b.Role == roleName { found = true continue } filtered = append(filtered, b) } if !found { return fmt.Errorf("rbac: binding not found for %s %q → %q", subjectType, subject, roleName) } return s.writeBindings(filtered) } // GetUserRoles returns all roles assigned to a user (directly and via groups). func (s *Store) GetUserRoles(username string) ([]string, error) { bindings, err := s.LoadBindings() if err != nil { return nil, err } roleSet := make(map[string]bool) // Get user's OS groups for group-based matching userGroups := getUserGroups(username) for _, b := range bindings { if b.SubjectType == "user" && b.Subject == username { roleSet[b.Role] = true } else if b.SubjectType == "group" { for _, g := range userGroups { if b.Subject == g { roleSet[b.Role] = true } } } } roles := make([]string, 0, len(roleSet)) for r := range roleSet { roles = append(roles, r) } return roles, nil } func (s *Store) loadBindingsUnsafe() []Binding { path := filepath.Join(s.dir, BindingsFile) data, err := os.ReadFile(path) if err != nil { return nil } var config struct { Bindings []Binding `yaml:"bindings"` } yaml.Unmarshal(data, &config) return config.Bindings } func (s *Store) writeBindings(bindings []Binding) error { if err := os.MkdirAll(s.dir, 0750); err != nil { return fmt.Errorf("rbac: create dir: %w", err) } config := struct { Bindings []Binding `yaml:"bindings"` }{Bindings: bindings} data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("rbac: marshal bindings: %w", err) } path := filepath.Join(s.dir, BindingsFile) return atomicWrite(path, data) } // ── Authorization ──────────────────────────────────────────────────────────── // Require checks if the current user has a specific permission. // Returns nil if authorized, error if not. // // Permission format: "resource.action" (e.g., "containers.create") // Wildcard: "resource.*" matches all actions for a resource // Admin wildcard: "*" matches everything func Require(permission string) error { store := NewStore("") return RequireWithStore(store, permission) } // RequireWithStore checks authorization using a specific store (for testing). func RequireWithStore(store *Store, permission string) error { username := CurrentUser() // Root always has full access if os.Geteuid() == 0 { return nil } // If RBAC is not configured, allow all (graceful degradation) if !store.isConfigured() { return nil } roleNames, err := store.GetUserRoles(username) if err != nil { return fmt.Errorf("rbac: failed to check roles for %q: %w", username, err) } if len(roleNames) == 0 { return fmt.Errorf("rbac: access denied — user %q has no assigned roles\n Ask an admin to run: volt rbac user assign %s ", username, username) } // Check each role for the required permission roles, err := store.LoadRoles() if err != nil { return fmt.Errorf("rbac: failed to load roles: %w", err) } roleMap := make(map[string]*Role) for i := range roles { roleMap[roles[i].Name] = &roles[i] } for _, rn := range roleNames { role, ok := roleMap[rn] if !ok { continue } if roleHasPermission(role, permission) { return nil } } return fmt.Errorf("rbac: access denied — user %q lacks permission %q\n Current roles: %s", username, permission, strings.Join(roleNames, ", ")) } // roleHasPermission checks if a role grants a specific permission. func roleHasPermission(role *Role, required string) bool { for _, perm := range role.Permissions { if perm == "*" { return true // Global wildcard } if perm == required { return true // Exact match } // Wildcard match: "containers.*" matches "containers.create" if strings.HasSuffix(perm, ".*") { prefix := strings.TrimSuffix(perm, ".*") if strings.HasPrefix(required, prefix+".") { return true } } } return false } // ── Identity ───────────────────────────────────────────────────────────────── // CurrentUser returns the identity of the current user. // Checks $VOLT_USER first, then falls back to OS user. func CurrentUser() string { if u := os.Getenv("VOLT_USER"); u != "" { return u } if u, err := user.Current(); err == nil { return u.Username } return "unknown" } // getUserGroups returns the OS groups for a given username. func getUserGroups(username string) []string { u, err := user.Lookup(username) if err != nil { return nil } gids, err := u.GroupIds() if err != nil { return nil } var groups []string for _, gid := range gids { g, err := user.LookupGroupId(gid) if err != nil { continue } groups = append(groups, g.Name) } return groups } // isConfigured returns true if RBAC has been set up (bindings file exists). func (s *Store) isConfigured() bool { path := filepath.Join(s.dir, BindingsFile) _, err := os.Stat(path) return err == nil } // ── Helpers ────────────────────────────────────────────────────────────────── // atomicWrite writes data to a file using tmp+rename for crash safety. func atomicWrite(path string, data []byte) error { tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0640); err != nil { return err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return err } return nil } // Init initializes the RBAC directory with default configuration. // Called by `volt rbac init`. func (s *Store) Init() error { s.mu.Lock() defer s.mu.Unlock() if err := os.MkdirAll(s.dir, 0750); err != nil { return fmt.Errorf("rbac: create dir: %w", err) } // Write default roles file (documenting built-ins, no custom roles yet) rolesData := `# Volt RBAC Role Definitions # Built-in roles (admin, operator, deployer, viewer) are always available. # Add custom roles below. roles: [] ` rolesPath := filepath.Join(s.dir, RolesFile) if err := os.WriteFile(rolesPath, []byte(rolesData), 0640); err != nil { return fmt.Errorf("rbac: write roles: %w", err) } // Write empty bindings file bindingsData := `# Volt RBAC Bindings — user/group to role mappings # Example: # bindings: # - subject: karl # subject_type: user # role: admin # - subject: developers # subject_type: group # role: deployer bindings: [] ` bindingsPath := filepath.Join(s.dir, BindingsFile) if err := os.WriteFile(bindingsPath, []byte(bindingsData), 0640); err != nil { return fmt.Errorf("rbac: write bindings: %w", err) } return nil }