feat(api): implement API key management and authentication enhancements
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped

- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation.
- Introduced a new Auth session endpoint to retrieve current tenant and role information.
- Updated the authentication middleware to support API key-based authentication and track last used timestamps.
- Enhanced documentation to reflect new API key functionalities and usage guidelines.
- Improved logging for demo authentication scenarios.
This commit is contained in:
Denozordec
2026-05-21 11:26:17 +07:00
parent 880d77810a
commit 6329a4df27
28 changed files with 1682 additions and 39 deletions
+63
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"strings"
"time"
)
@@ -88,6 +89,15 @@ type Backend interface {
ListGlobalSettings(tenantID string) (map[string]any, error)
PatchGlobalSettings(tenantID string, patch map[string]any) error
ListAPIKeys(tenantID string) ([]*APIKey, error)
GetAPIKey(tenantID, id string) (*APIKey, error)
CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error)
UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error)
RevokeAPIKey(tenantID, id string) error
RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error)
ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error)
TouchAPIKeyLastUsed(id string) error
// Module prefix snapshots cache last successful collect per module (pipeline ingest/render).
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
@@ -227,6 +237,59 @@ type CommunityPatch struct {
ValueJSON *string `json:"value_json,omitempty"`
}
// APIKey is tenant-scoped API key metadata (secret never stored in plaintext).
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Role string `json:"role"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// APIKeyCreate is input for issuing a new key.
type APIKeyCreate struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APIKeyPatch is a partial update (role change affects auth after resolver reload).
type APIKeyPatch struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
ClearExpiresAt bool `json:"-"`
}
// APIKeyWithSecret is returned only on create/rotate.
type APIKeyWithSecret struct {
APIKey
Token string `json:"token"`
}
// APIKeyAuthRow is used to build the in-process auth index.
type APIKeyAuthRow struct {
ID string
TenantID string
Role string
TokenHash []byte
}
// ValidAPIKeyRole reports whether role is allowed for API keys.
func ValidAPIKeyRole(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "viewer", "editor", "operator", "node":
return true
default:
return false
}
}
type PeerPatch struct {
Neighbor *string `json:"neighbor,omitempty"`
RemoteASN *int64 `json:"remote_asn,omitempty"`
+7
View File
@@ -43,6 +43,7 @@ type Memory struct {
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -57,6 +58,11 @@ type publishedInfo struct {
PublishedAt time.Time
}
type apiKeyRec struct {
APIKey
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -133,6 +139,7 @@ func NewMemory() *Memory {
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
}
}
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"strings"
"time"
"evobgp/internal/authkey"
"github.com/google/uuid"
)
func (m *Memory) ListAPIKeys(tenantID string) ([]*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*APIKey
for _, rec := range m.apiKeys {
if rec.TenantID == tenantID {
out = append(out, apiKeyCopy(&rec.APIKey))
}
}
return out, nil
}
func (m *Memory) GetAPIKey(tenantID, id string) (*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !ValidAPIKeyRole(in.Role) {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
id := uuid.NewString()
k := &apiKeyRec{
APIKey: APIKey{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Role: strings.ToLower(strings.TrimSpace(in.Role)),
Prefix: authkey.Prefix(tok),
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: in.ExpiresAt,
},
TokenHash: authkey.HashToken(tok),
}
m.apiKeys[id] = k
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&k.APIKey), Token: tok}, nil
}
func (m *Memory) UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Role != nil {
if !ValidAPIKeyRole(*patch.Role) {
return nil, ErrInvalidInput
}
rec.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
rec.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
rec.ExpiresAt = patch.ExpiresAt
}
rec.UpdatedAt = time.Now().UTC()
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) RevokeAPIKey(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.RevokedAt = &now
rec.UpdatedAt = now
return nil
}
func (m *Memory) RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec.TokenHash = authkey.HashToken(tok)
rec.Prefix = authkey.Prefix(tok)
rec.UpdatedAt = now
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&rec.APIKey), Token: tok}, nil
}
func (m *Memory) ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now().UTC()
var out []APIKeyAuthRow
for _, rec := range m.apiKeys {
if rec.RevokedAt != nil {
continue
}
if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) {
continue
}
out = append(out, APIKeyAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
Role: rec.Role,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) TouchAPIKeyLastUsed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastUsedAt = &now
return nil
}
func apiKeyCopy(k *APIKey) *APIKey {
cp := *k
if k.ExpiresAt != nil {
t := *k.ExpiresAt
cp.ExpiresAt = &t
}
if k.RevokedAt != nil {
t := *k.RevokedAt
cp.RevokedAt = &t
}
if k.LastUsedAt != nil {
t := *k.LastUsedAt
cp.LastUsedAt = &t
}
return &cp
}