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:
642
pkg/rbac/rbac.go
Normal file
642
pkg/rbac/rbac.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user