mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-07-13 04:57:11 +00:00
e39d6a0f0d
Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.
SECURITY & CORRECTNESS
- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
(RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
--detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).
FUNCTIONAL FEATURES
- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
(skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
realtime store, Dashboard live indicator + activity feed + reactive stats
override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.
LINT/HYGIENE
- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
field selectors + QF1001 De Morgan's law.
BEHAVIOR CHANGES
- Scanner now fail-closed: deployments without scanner binaries
(trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.
NEW PACKAGES
- pkg/events - Broadcaster interface
- pkg/storage/nfs - NFS-aware filesystem wrapper
DEFERRED (out of scope this pass)
- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
integrator scope).
452 lines
12 KiB
Go
452 lines
12 KiB
Go
// Package auth implements API key issuance, validation, and credential
|
|
// extraction for upstream-registry pass-through authentication.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/gohoarder/pkg/errors"
|
|
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// Config controls Manager behaviour. Zero value is valid: bcrypt cost defaults
|
|
// to bcrypt.DefaultCost, store is nil (in-memory only).
|
|
type Config struct {
|
|
// BcryptCost is the cost parameter passed to bcrypt.GenerateFromPassword.
|
|
// 0 means use bcrypt.DefaultCost. Tests typically use bcrypt.MinCost.
|
|
BcryptCost int
|
|
}
|
|
|
|
// Manager handles authentication and authorization.
|
|
//
|
|
// Persistence model:
|
|
// - All keys live in an in-memory map keyed by APIKey.ID for O(1) revoke
|
|
// and fast bcrypt iteration during validation.
|
|
// - When a metadata.MetadataStore is wired in via NewWithStore, mutations
|
|
// (Generate, Revoke) are mirrored to the store synchronously and
|
|
// LastUsedAt updates are flushed asynchronously to avoid blocking the
|
|
// hot validation path on a DB round-trip.
|
|
// - When the store is nil, Manager is purely in-memory (back-compat for
|
|
// tests and unconfigured deployments).
|
|
type Manager struct {
|
|
keys map[string]*APIKey
|
|
store metadata.MetadataStore
|
|
cfg Config
|
|
mu sync.RWMutex
|
|
bgWG sync.WaitGroup
|
|
closed bool
|
|
}
|
|
|
|
// APIKey represents an API key
|
|
type APIKey struct {
|
|
ID string
|
|
Name string
|
|
HashedKey string
|
|
Role Role
|
|
Project string
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
LastUsedAt time.Time
|
|
Permissions []Permission
|
|
}
|
|
|
|
// Role represents user role
|
|
type Role string
|
|
|
|
const (
|
|
RoleReadOnly Role = "readonly"
|
|
RoleReadWrite Role = "readwrite"
|
|
RoleAdmin Role = "admin"
|
|
)
|
|
|
|
// Persistent role identifiers stored in metadata.APIKey.Role. Different from
|
|
// the in-memory Role values for backward compatibility with existing API
|
|
// surfaces while matching the storage schema described in the spec.
|
|
const (
|
|
storedRoleReadOnly = "read_only"
|
|
storedRoleReadWrite = "read_write"
|
|
storedRoleAdmin = "admin"
|
|
)
|
|
|
|
// Permission represents a specific permission
|
|
type Permission string
|
|
|
|
const (
|
|
PermissionReadPackage Permission = "package:read"
|
|
PermissionWritePackage Permission = "package:write"
|
|
PermissionDeletePackage Permission = "package:delete"
|
|
PermissionViewStats Permission = "stats:view"
|
|
PermissionManageKeys Permission = "keys:manage"
|
|
PermissionManageSettings Permission = "settings:manage"
|
|
PermissionScanPackages Permission = "scan:execute"
|
|
PermissionManageBypasses Permission = "bypasses:manage"
|
|
)
|
|
|
|
// New creates a new authentication manager (in-memory only).
|
|
// Equivalent to NewWithStore(Config{}, nil).
|
|
func New() *Manager {
|
|
return NewWithStore(Config{}, nil)
|
|
}
|
|
|
|
// NewWithStore creates a Manager backed by an optional metadata store.
|
|
// Pass store=nil for purely in-memory mode (tests, ephemeral setups).
|
|
func NewWithStore(cfg Config, store metadata.MetadataStore) *Manager {
|
|
if cfg.BcryptCost == 0 {
|
|
cfg.BcryptCost = bcrypt.DefaultCost
|
|
}
|
|
return &Manager{
|
|
keys: make(map[string]*APIKey),
|
|
store: store,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// Load pulls all non-revoked keys from the store into the in-memory map.
|
|
// Safe to call multiple times: it replaces the in-memory snapshot.
|
|
// No-op when store is nil or returns ErrNotImplemented.
|
|
func (m *Manager) Load(ctx context.Context) error {
|
|
if m.store == nil {
|
|
return nil
|
|
}
|
|
|
|
stored, err := m.store.ListAPIKeys(ctx)
|
|
if err != nil {
|
|
if stderrors.Is(err, metadata.ErrNotImplemented) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
loaded := make(map[string]*APIKey, len(stored))
|
|
for _, k := range stored {
|
|
if k == nil || k.Revoked {
|
|
continue
|
|
}
|
|
loaded[k.ID] = storedToMemory(k)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.keys = loaded
|
|
m.mu.Unlock()
|
|
|
|
log.Info().Int("count", len(loaded)).Msg("Loaded API keys from metadata store")
|
|
return nil
|
|
}
|
|
|
|
// GenerateAPIKey generates a new API key.
|
|
//
|
|
// When a store is configured, the key is persisted before returning. If the
|
|
// store rejects the write the in-memory state is rolled back and the error
|
|
// is propagated; we never return a key the caller cannot actually use after
|
|
// a restart.
|
|
func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duration) (*APIKey, string, error) {
|
|
// Generate random key
|
|
keyBytes := make([]byte, 32)
|
|
if _, err := rand.Read(keyBytes); err != nil {
|
|
return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to generate random key")
|
|
}
|
|
|
|
rawKey := base64.URLEncoding.EncodeToString(keyBytes)
|
|
|
|
// Hash the key with the configured cost.
|
|
hashedKey, err := bcrypt.GenerateFromPassword([]byte(rawKey), m.cfg.BcryptCost)
|
|
if err != nil {
|
|
return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to hash key")
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if expiresIn != nil {
|
|
t := time.Now().Add(*expiresIn)
|
|
expiresAt = &t
|
|
}
|
|
|
|
apiKey := &APIKey{
|
|
ID: generateID(),
|
|
Name: name,
|
|
HashedKey: string(hashedKey),
|
|
Role: role,
|
|
CreatedAt: time.Now(),
|
|
ExpiresAt: expiresAt,
|
|
Permissions: getPermissionsForRole(role),
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.keys[apiKey.ID] = apiKey
|
|
m.mu.Unlock()
|
|
|
|
if m.store != nil {
|
|
stored := memoryToStored(apiKey)
|
|
if err := m.store.SaveAPIKey(context.Background(), stored); err != nil {
|
|
if !stderrors.Is(err, metadata.ErrNotImplemented) {
|
|
// Rollback in-memory insert so caller sees a consistent failure.
|
|
m.mu.Lock()
|
|
delete(m.keys, apiKey.ID)
|
|
m.mu.Unlock()
|
|
return nil, "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to persist api key")
|
|
}
|
|
}
|
|
}
|
|
|
|
return apiKey, rawKey, nil
|
|
}
|
|
|
|
// ValidateAPIKey validates an API key and returns the associated key object.
|
|
// Hot path: snapshots candidates, runs bcrypt without holding the lock.
|
|
// LastUsedAt is updated in-memory under the write lock and flushed to the
|
|
// store via a fire-and-forget goroutine.
|
|
func (m *Manager) ValidateAPIKey(ctx context.Context, rawKey string) (*APIKey, error) {
|
|
// Phase 1: snapshot non-expired candidates under RLock. We hold pointers
|
|
// to APIKey; APIKey.HashedKey is an immutable string so reading it
|
|
// without a lock is safe even if the key is later removed from the map.
|
|
m.mu.RLock()
|
|
candidates := make([]*APIKey, 0, len(m.keys))
|
|
now := time.Now()
|
|
for _, apiKey := range m.keys {
|
|
if apiKey.ExpiresAt != nil && now.After(*apiKey.ExpiresAt) {
|
|
continue
|
|
}
|
|
candidates = append(candidates, apiKey)
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Phase 2: run bcrypt comparisons without holding any lock so concurrent
|
|
// auth checks can proceed in parallel.
|
|
for _, apiKey := range candidates {
|
|
if err := bcrypt.CompareHashAndPassword([]byte(apiKey.HashedKey), []byte(rawKey)); err == nil {
|
|
// Phase 3: briefly take the write lock to update LastUsedAt.
|
|
// Re-check the key still exists in the map to avoid resurrecting
|
|
// a revoked key's metadata.
|
|
usedAt := time.Now()
|
|
m.mu.Lock()
|
|
if _, ok := m.keys[apiKey.ID]; ok {
|
|
apiKey.LastUsedAt = usedAt
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
// Async store update: never block validation on a DB write.
|
|
m.persistLastUsedAsync(apiKey.ID, usedAt)
|
|
return apiKey, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New(errors.ErrCodeUnauthorized, "invalid API key")
|
|
}
|
|
|
|
// persistLastUsedAsync flushes a LastUsedAt update to the store off the hot
|
|
// path. Errors are logged but not propagated — the validation succeeded
|
|
// in-memory and that is the source of truth for liveness.
|
|
func (m *Manager) persistLastUsedAsync(id string, t time.Time) {
|
|
if m.store == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.bgWG.Add(1)
|
|
m.mu.Unlock()
|
|
|
|
go func() {
|
|
defer m.bgWG.Done()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := m.store.UpdateAPIKeyLastUsed(ctx, id, t); err != nil {
|
|
if !stderrors.Is(err, metadata.ErrNotImplemented) {
|
|
log.Warn().Err(err).Str("key_id", id).Msg("Failed to persist api key LastUsedAt")
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// RevokeAPIKey revokes an API key. With a store configured, revocation is
|
|
// persisted (Revoked=true) so the key remains queryable for audit but never
|
|
// authenticates again.
|
|
func (m *Manager) RevokeAPIKey(keyID string) error {
|
|
m.mu.Lock()
|
|
existing, ok := m.keys[keyID]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return errors.NotFound("API key not found")
|
|
}
|
|
delete(m.keys, keyID)
|
|
m.mu.Unlock()
|
|
|
|
if m.store != nil {
|
|
stored := memoryToStored(existing)
|
|
stored.Revoked = true
|
|
if err := m.store.SaveAPIKey(context.Background(), stored); err != nil {
|
|
if !stderrors.Is(err, metadata.ErrNotImplemented) {
|
|
// Rollback: re-add to memory so subsequent validation still
|
|
// sees it (rather than silently leaving a window where the
|
|
// key works in-memory only on this replica).
|
|
m.mu.Lock()
|
|
m.keys[keyID] = existing
|
|
m.mu.Unlock()
|
|
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to persist revocation")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListAPIKeys lists all API keys
|
|
func (m *Manager) ListAPIKeys() []*APIKey {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
keys := make([]*APIKey, 0, len(m.keys))
|
|
for _, key := range m.keys {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// Close waits for any in-flight background writes (LastUsedAt updates) to
|
|
// finish. Safe to call multiple times.
|
|
func (m *Manager) Close() {
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.closed = true
|
|
m.mu.Unlock()
|
|
m.bgWG.Wait()
|
|
}
|
|
|
|
// HasPermission checks if an API key has a specific permission
|
|
func (k *APIKey) HasPermission(permission Permission) bool {
|
|
for _, p := range k.Permissions {
|
|
if p == permission {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getPermissionsForRole returns permissions for a role
|
|
func getPermissionsForRole(role Role) []Permission {
|
|
switch role {
|
|
case RoleReadOnly:
|
|
return []Permission{
|
|
PermissionReadPackage,
|
|
PermissionViewStats,
|
|
}
|
|
case RoleReadWrite:
|
|
return []Permission{
|
|
PermissionReadPackage,
|
|
PermissionWritePackage,
|
|
PermissionViewStats,
|
|
}
|
|
case RoleAdmin:
|
|
return []Permission{
|
|
PermissionReadPackage,
|
|
PermissionWritePackage,
|
|
PermissionDeletePackage,
|
|
PermissionViewStats,
|
|
PermissionManageKeys,
|
|
PermissionManageSettings,
|
|
PermissionScanPackages,
|
|
PermissionManageBypasses,
|
|
}
|
|
default:
|
|
return []Permission{}
|
|
}
|
|
}
|
|
|
|
// generateID generates a unique ID. Panics on crypto/rand failure: a
|
|
// zero-byte ID would be a security catastrophe (collisions, predictability).
|
|
func generateID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(fmt.Sprintf("auth: crypto/rand read failed: %v", err))
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// memoryToStored converts an in-memory APIKey to its metadata-layer form.
|
|
// Permissions are deliberately not encoded here: they are derived from Role
|
|
// at load time, keeping the persisted representation compact.
|
|
func memoryToStored(k *APIKey) *metadata.APIKey {
|
|
var lastUsed *time.Time
|
|
if !k.LastUsedAt.IsZero() {
|
|
t := k.LastUsedAt
|
|
lastUsed = &t
|
|
}
|
|
|
|
return &metadata.APIKey{
|
|
ID: k.ID,
|
|
KeyHash: k.HashedKey,
|
|
Project: k.Project,
|
|
Role: roleToStored(k.Role),
|
|
CreatedAt: k.CreatedAt,
|
|
ExpiresAt: k.ExpiresAt,
|
|
LastUsedAt: lastUsed,
|
|
Revoked: false,
|
|
}
|
|
}
|
|
|
|
// storedToMemory converts a metadata.APIKey to its runtime form. Permissions
|
|
// are derived from Role to keep storage minimal.
|
|
func storedToMemory(k *metadata.APIKey) *APIKey {
|
|
role := storedToRole(k.Role)
|
|
out := &APIKey{
|
|
ID: k.ID,
|
|
Name: "", // not persisted
|
|
HashedKey: k.KeyHash,
|
|
Role: role,
|
|
Project: k.Project,
|
|
CreatedAt: k.CreatedAt,
|
|
ExpiresAt: k.ExpiresAt,
|
|
Permissions: getPermissionsForRole(role),
|
|
}
|
|
if k.LastUsedAt != nil {
|
|
out.LastUsedAt = *k.LastUsedAt
|
|
}
|
|
return out
|
|
}
|
|
|
|
// roleToStored maps the in-memory Role enum to its persisted string form.
|
|
func roleToStored(r Role) string {
|
|
switch r {
|
|
case RoleReadOnly:
|
|
return storedRoleReadOnly
|
|
case RoleReadWrite:
|
|
return storedRoleReadWrite
|
|
case RoleAdmin:
|
|
return storedRoleAdmin
|
|
default:
|
|
return string(r)
|
|
}
|
|
}
|
|
|
|
// storedToRole inverts roleToStored. Unknown values fall back to RoleReadOnly
|
|
// (least-privilege) rather than failing — guards against schema drift.
|
|
func storedToRole(s string) Role {
|
|
switch s {
|
|
case storedRoleAdmin:
|
|
return RoleAdmin
|
|
case storedRoleReadWrite:
|
|
return RoleReadWrite
|
|
case storedRoleReadOnly:
|
|
return RoleReadOnly
|
|
}
|
|
// Tolerate legacy in-memory values that may have been persisted.
|
|
switch Role(s) {
|
|
case RoleAdmin, RoleReadWrite, RoleReadOnly:
|
|
return Role(s)
|
|
}
|
|
return RoleReadOnly
|
|
}
|