Volt CLI: source-available under AGPSL v5.0

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
This commit is contained in:
Karl Clinger
2026-03-21 00:30:23 -05:00
commit 81ad0b597c
106 changed files with 35984 additions and 0 deletions

642
pkg/rbac/rbac.go Normal file
View File

@@ -0,0 +1,642 @@
/*
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
}