Pulse/internal/config/api_tokens.go
rcourtman 5c54685f04 Add API token scopes and standalone host agent
Introduces granular permission scopes for API tokens (docker:report, docker:manage, host-agent:report, monitoring:read/write, settings:read/write) allowing tokens to be restricted to minimum required access. Legacy tokens default to full access until scopes are explicitly configured.

Adds standalone host agent for monitoring Linux, macOS, and Windows servers outside Proxmox/Docker estates. New Servers workspace in UI displays uptime, OS metadata, and capacity metrics from enrolled agents.

Includes comprehensive token management UI overhaul with scope presets, inline editing, and visual scope indicators.
2025-10-23 11:40:31 +00:00

281 lines
7 KiB
Go

package config
import (
"errors"
"sort"
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
)
// Canonical API token scope strings.
const (
ScopeWildcard = "*"
ScopeMonitoringRead = "monitoring:read"
ScopeMonitoringWrite = "monitoring:write"
ScopeDockerReport = "docker:report"
ScopeDockerManage = "docker:manage"
ScopeHostReport = "host-agent:report"
ScopeSettingsRead = "settings:read"
ScopeSettingsWrite = "settings:write"
)
// AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel).
var AllKnownScopes = []string{
ScopeMonitoringRead,
ScopeMonitoringWrite,
ScopeDockerReport,
ScopeDockerManage,
ScopeHostReport,
ScopeSettingsRead,
ScopeSettingsWrite,
}
var scopeLookup = func() map[string]struct{} {
lookup := make(map[string]struct{}, len(AllKnownScopes))
for _, scope := range AllKnownScopes {
lookup[scope] = struct{}{}
}
return lookup
}()
// ErrInvalidToken is returned when a token value is empty or malformed.
var ErrInvalidToken = errors.New("invalid API token")
// APITokenRecord stores hashed token metadata.
type APITokenRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Hash string `json:"hash"`
Prefix string `json:"prefix,omitempty"`
Suffix string `json:"suffix,omitempty"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// ensureScopes normalizes the scope slice, applying legacy defaults.
func (r *APITokenRecord) ensureScopes() {
if len(r.Scopes) == 0 {
r.Scopes = []string{ScopeWildcard}
return
}
// Copy to avoid shared underlying slice if this record is reused.
scopes := make([]string, len(r.Scopes))
copy(scopes, r.Scopes)
r.Scopes = scopes
}
// Clone returns a copy of the record with duplicated pointer fields.
func (r *APITokenRecord) Clone() APITokenRecord {
clone := *r
if r.LastUsedAt != nil {
t := *r.LastUsedAt
clone.LastUsedAt = &t
}
clone.ensureScopes()
return clone
}
// NewAPITokenRecord constructs a metadata record from the provided raw token.
func NewAPITokenRecord(rawToken, name string, scopes []string) (*APITokenRecord, error) {
if rawToken == "" {
return nil, ErrInvalidToken
}
now := time.Now().UTC()
record := &APITokenRecord{
ID: uuid.NewString(),
Name: name,
Hash: auth.HashAPIToken(rawToken),
Prefix: tokenPrefix(rawToken),
Suffix: tokenSuffix(rawToken),
CreatedAt: now,
Scopes: normalizeScopes(scopes),
}
return record, nil
}
// NewHashedAPITokenRecord constructs a record from an already hashed token.
func NewHashedAPITokenRecord(hashedToken, name string, createdAt time.Time, scopes []string) (*APITokenRecord, error) {
if hashedToken == "" {
return nil, ErrInvalidToken
}
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
return &APITokenRecord{
ID: uuid.NewString(),
Name: name,
Hash: hashedToken,
Prefix: tokenPrefix(hashedToken),
Suffix: tokenSuffix(hashedToken),
CreatedAt: createdAt,
Scopes: normalizeScopes(scopes),
}, nil
}
// tokenPrefix returns the first six characters suitable for hints.
func tokenPrefix(value string) string {
if len(value) >= 6 {
return value[:6]
}
return value
}
// tokenSuffix returns the last four characters suitable for hints.
func tokenSuffix(value string) string {
if len(value) >= 4 {
return value[len(value)-4:]
}
return value
}
// HasAPITokens reports whether any API tokens are configured.
func (c *Config) HasAPITokens() bool {
return len(c.APITokens) > 0
}
// APITokenCount returns the number of configured tokens.
func (c *Config) APITokenCount() int {
return len(c.APITokens)
}
// ActiveAPITokenHashes returns all stored token hashes.
func (c *Config) ActiveAPITokenHashes() []string {
hashes := make([]string, 0, len(c.APITokens))
for _, record := range c.APITokens {
if record.Hash != "" {
hashes = append(hashes, record.Hash)
}
}
return hashes
}
// HasAPITokenHash returns true when the hash already exists.
func (c *Config) HasAPITokenHash(hash string) bool {
for _, record := range c.APITokens {
if record.Hash == hash {
return true
}
}
return false
}
// PrimaryAPITokenHash returns the newest token hash, if any.
func (c *Config) PrimaryAPITokenHash() string {
if len(c.APITokens) == 0 {
return ""
}
return c.APITokens[0].Hash
}
// PrimaryAPITokenHint provides a human-friendly token hint for UI display.
func (c *Config) PrimaryAPITokenHint() string {
if len(c.APITokens) == 0 {
return ""
}
token := c.APITokens[0]
if token.Prefix != "" && token.Suffix != "" {
return token.Prefix + "..." + token.Suffix
}
if len(token.Hash) >= 8 {
return token.Hash[:4] + "..." + token.Hash[len(token.Hash)-4:]
}
return ""
}
// ValidateAPIToken compares the raw token against stored hashes and updates metadata.
func (c *Config) ValidateAPIToken(rawToken string) (*APITokenRecord, bool) {
if rawToken == "" {
return nil, false
}
for idx, record := range c.APITokens {
if auth.CompareAPIToken(rawToken, record.Hash) {
now := time.Now().UTC()
c.APITokens[idx].LastUsedAt = &now
c.APITokens[idx].ensureScopes()
return &c.APITokens[idx], true
}
}
return nil, false
}
// UpsertAPIToken inserts or replaces a record by ID.
func (c *Config) UpsertAPIToken(record APITokenRecord) {
record.ensureScopes()
for idx, existing := range c.APITokens {
if existing.ID == record.ID {
c.APITokens[idx] = record
c.SortAPITokens()
return
}
}
c.APITokens = append(c.APITokens, record)
c.SortAPITokens()
}
// RemoveAPIToken removes a token by ID.
func (c *Config) RemoveAPIToken(id string) bool {
for idx, record := range c.APITokens {
if record.ID == id {
c.APITokens = append(c.APITokens[:idx], c.APITokens[idx+1:]...)
return true
}
}
return false
}
// SortAPITokens keeps tokens ordered newest-first and syncs the legacy APIToken field.
func (c *Config) SortAPITokens() {
for i := range c.APITokens {
c.APITokens[i].ensureScopes()
}
sort.SliceStable(c.APITokens, func(i, j int) bool {
return c.APITokens[i].CreatedAt.After(c.APITokens[j].CreatedAt)
})
if len(c.APITokens) > 0 {
c.APIToken = c.APITokens[0].Hash
c.APITokenEnabled = true
} else {
c.APIToken = ""
}
}
// normalizeScopes applies defaults and returns a safe copy of the input slice.
func normalizeScopes(scopes []string) []string {
if len(scopes) == 0 {
return []string{ScopeWildcard}
}
result := make([]string, len(scopes))
copy(result, scopes)
return result
}
// HasScope reports whether the record grants the requested scope or wildcard access.
func (r *APITokenRecord) HasScope(scope string) bool {
if scope == "" {
return true
}
r.ensureScopes()
for _, candidate := range r.Scopes {
if candidate == ScopeWildcard || candidate == scope {
return true
}
}
return false
}
// IsKnownScope reports whether the provided string matches a supported scope identifier.
func IsKnownScope(scope string) bool {
if scope == ScopeWildcard {
return true
}
_, ok := scopeLookup[scope]
return ok
}