feat(ui): add identity management for tenants and users
CI / build-and-test (push) Successful in 1m47s

* Implement tenant and user creation in IdentityPage
* Add API calls for managing tenants and users
* Introduce tenant-scoped API requests
* Update sidebar to include identity navigation
* Create BooleanStatusBadge component for key status
This commit is contained in:
2026-07-20 22:50:55 +02:00
parent 3d4e6d0939
commit 196b543bee
64 changed files with 3363 additions and 615 deletions
+113 -2
View File
@@ -1,14 +1,27 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"strings"
"sync"
"git.warky.dev/wdevs/amcs/internal/config"
)
type Keyring struct {
keys []config.APIKey
mu sync.RWMutex
keys []config.APIKey
tenantsByKeyID map[string]string
managed map[string]managedKey
}
type managedKey struct {
secretHash string
enabled bool
}
func NewKeyring(keys []config.APIKey) (*Keyring, error) {
@@ -16,14 +29,112 @@ func NewKeyring(keys []config.APIKey) (*Keyring, error) {
return nil, fmt.Errorf("keyring requires at least one key")
}
return &Keyring{keys: append([]config.APIKey(nil), keys...)}, nil
tenantsByKeyID := make(map[string]string)
for _, key := range keys {
if tenantID := strings.TrimSpace(key.TenantID); tenantID != "" {
tenantsByKeyID[key.ID] = tenantID
}
}
return &Keyring{keys: append([]config.APIKey(nil), keys...), tenantsByKeyID: tenantsByKeyID, managed: make(map[string]managedKey)}, nil
}
// NewManagedKeyring is used when API credentials are administered in the
// database rather than supplied through static configuration.
func NewManagedKeyring() *Keyring {
return &Keyring{tenantsByKeyID: make(map[string]string), managed: make(map[string]managedKey)}
}
func (k *Keyring) Lookup(value string) (string, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
for _, key := range k.keys {
if subtle.ConstantTimeCompare([]byte(key.Value), []byte(value)) == 1 {
return key.ID, true
}
}
hash := secretHash(value)
for keyID, key := range k.managed {
if key.enabled && subtle.ConstantTimeCompare([]byte(key.secretHash), []byte(hash)) == 1 {
return keyID, true
}
}
return "", false
}
// TenantForKey returns the tenant boundary assigned to keyID. Unassigned
// configured keys retain the historical key-ID boundary for compatibility.
func (k *Keyring) TenantForKey(keyID string) string {
k.mu.RLock()
defer k.mu.RUnlock()
if tenantID := k.tenantsByKeyID[keyID]; tenantID != "" {
return tenantID
}
return keyID
}
func (k *Keyring) AssignTenant(keyID, tenantID string) {
k.mu.Lock()
defer k.mu.Unlock()
if tenantID == "" {
delete(k.tenantsByKeyID, keyID)
return
}
k.tenantsByKeyID[keyID] = tenantID
}
func (k *Keyring) AddManaged(keyID, secretHash string, enabled bool) {
k.mu.Lock()
defer k.mu.Unlock()
k.managed[keyID] = managedKey{secretHash: secretHash, enabled: enabled}
}
func (k *Keyring) ConfiguredKeys() []config.APIKey {
k.mu.RLock()
defer k.mu.RUnlock()
return append([]config.APIKey(nil), k.keys...)
}
func (k *Keyring) IsConfigured(keyID string) bool {
k.mu.RLock()
defer k.mu.RUnlock()
for _, key := range k.keys {
if key.ID == keyID {
return true
}
}
return false
}
func (k *Keyring) IsSuperadmin(keyID string) bool {
k.mu.RLock()
defer k.mu.RUnlock()
for _, key := range k.keys {
if key.ID == keyID {
return key.Superadmin
}
}
return false
}
func (k *Keyring) SetManagedEnabled(keyID string, enabled bool) {
k.mu.Lock()
defer k.mu.Unlock()
if key, ok := k.managed[keyID]; ok {
key.enabled = enabled
k.managed[keyID] = key
}
}
func GenerateSecret() (string, string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
secret := "amcs_" + hex.EncodeToString(b)
return secret, secretHash(secret), nil
}
func secretHash(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
+20
View File
@@ -36,6 +36,26 @@ func TestNewKeyringAndLookup(t *testing.T) {
}
}
func TestConfiguredKeyUsesTenantID(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "agent-key", Value: "secret", TenantID: "acme"}})
if err != nil {
t.Fatalf("NewKeyring() error = %v", err)
}
if got := keyring.TenantForKey("agent-key"); got != "acme" {
t.Fatalf("TenantForKey() = %q, want acme", got)
}
}
func TestConfiguredKeySuperadmin(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "operator", Value: "secret", Superadmin: true}})
if err != nil {
t.Fatalf("NewKeyring() error = %v", err)
}
if !keyring.IsSuperadmin("operator") || keyring.IsSuperadmin("missing") {
t.Fatal("IsSuperadmin() did not return the configured role")
}
}
func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
if err != nil {
+5 -1
View File
@@ -53,7 +53,11 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
}
withTenant := func(ctx context.Context, keyID string) context.Context {
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
return tenancy.WithTenantKey(ctx, keyID)
tenantID := keyID
if keyring != nil {
tenantID = keyring.TenantForKey(keyID)
}
return tenancy.WithTenantKey(ctx, tenantID)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {