Complete infrastructure platform CLI: - Container runtime (systemd-nspawn) - VoltVisor VMs (Neutron Stardust / QEMU) - Stellarium CAS (content-addressed storage) - ORAS Registry - GitOps integration - Landlock LSM security - Compose orchestration - Mesh networking Copyright (c) Armored Gates LLC. All rights reserved. Licensed under AGPSL v5.0
643 lines
16 KiB
Go
643 lines
16 KiB
Go
/*
|
|
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 <role>", 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
|
|
}
|