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
+364
View File
@@ -0,0 +1,364 @@
package app
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.warky.dev/wdevs/amcs/internal/auth"
)
type identityAdmin struct {
pool *pgxpool.Pool
keyring *auth.Keyring
logger *slog.Logger
}
func newIdentityAdmin(pool *pgxpool.Pool, keyring *auth.Keyring, logger *slog.Logger) *identityAdmin {
return &identityAdmin{pool: pool, keyring: keyring, logger: logger}
}
func loadIdentityKeyring(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
if keyring == nil {
return nil
}
rows, err := pool.Query(ctx, `select a.key_id, a.tenant_id, a.enabled, coalesce(m.secret_hash, '') from api_key_assignments a left join managed_api_keys m on m.key_id = a.key_id`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var keyID, tenantID, hash string
var enabled bool
if err := rows.Scan(&keyID, &tenantID, &enabled, &hash); err != nil {
return err
}
keyring.AssignTenant(keyID, tenantID)
if hash != "" {
keyring.AddManaged(keyID, hash, enabled)
}
}
return rows.Err()
}
// ensureConfiguredTenants makes a tenant referenced in static YAML visible to
// the admin UI as well as to the authentication middleware.
func ensureConfiguredTenants(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
if keyring == nil {
return nil
}
for _, key := range keyring.ConfiguredKeys() {
tenantID := strings.TrimSpace(key.TenantID)
if tenantID == "" {
continue
}
if _, err := pool.Exec(ctx, `insert into tenants (id, name) values ($1, $1) on conflict (id) do nothing`, tenantID); err != nil {
return err
}
}
return nil
}
func (a *identityAdmin) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
keyID, ok := auth.KeyIDFromContext(r.Context())
if !ok || a.keyring == nil || !a.keyring.IsSuperadmin(keyID) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "superadmin API key required"})
return
}
}
path := strings.TrimPrefix(r.URL.Path, "/api/admin/identity")
switch {
case r.Method == http.MethodGet && path == "":
a.list(w, r)
case r.Method == http.MethodPost && path == "/tenants":
a.createTenant(w, r)
case r.Method == http.MethodPost && path == "/users":
a.createUser(w, r)
case r.Method == http.MethodPost && path == "/keys":
a.createKey(w, r)
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/keys/"):
a.updateKey(w, r, strings.TrimPrefix(path, "/keys/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/adopt-legacy"):
a.adoptLegacy(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/tenants/"), "/adopt-legacy"))
default:
http.NotFound(w, r)
}
})
}
type tenantDTO struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type userDTO struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type keyDTO struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
UserID *string `json:"user_id,omitempty"`
Description string `json:"description"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
func (a *identityAdmin) list(w http.ResponseWriter, r *http.Request) {
result := struct {
Tenants []tenantDTO `json:"tenants"`
Users []userDTO `json:"users"`
Keys []keyDTO `json:"keys"`
}{Tenants: []tenantDTO{}, Users: []userDTO{}, Keys: []keyDTO{}}
rows, err := a.pool.Query(r.Context(), `select id, name, created_at from tenants order by name`)
if err != nil {
identityError(w, err)
return
}
defer rows.Close()
for rows.Next() {
var x tenantDTO
if err := rows.Scan(&x.ID, &x.Name, &x.CreatedAt); err != nil {
identityError(w, err)
return
}
result.Tenants = append(result.Tenants, x)
}
rows, err = a.pool.Query(r.Context(), `select id, tenant_id, name, email, created_at from tenant_users order by name`)
if err != nil {
identityError(w, err)
return
}
defer rows.Close()
for rows.Next() {
var x userDTO
if err := rows.Scan(&x.ID, &x.TenantID, &x.Name, &x.Email, &x.CreatedAt); err != nil {
identityError(w, err)
return
}
result.Users = append(result.Users, x)
}
configured := make(map[string]authKey)
if a.keyring != nil {
for _, key := range a.keyring.ConfiguredKeys() {
configured[key.ID] = authKey{description: key.Description}
}
}
rows, err = a.pool.Query(r.Context(), `select key_id, tenant_id, user_id, description, source, enabled, created_at from api_key_assignments order by key_id`)
if err != nil {
identityError(w, err)
return
}
defer rows.Close()
for rows.Next() {
var x keyDTO
if err := rows.Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt); err != nil {
identityError(w, err)
return
}
result.Keys = append(result.Keys, x)
delete(configured, x.ID)
}
for id, key := range configured {
result.Keys = append(result.Keys, keyDTO{ID: id, Description: key.description, Source: "configured", Enabled: true})
}
writeJSON(w, http.StatusOK, result)
}
type authKey struct{ description string }
func (a *identityAdmin) createTenant(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if !decodeJSON(w, r, &body) {
return
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" {
badRequest(w, "name is required")
return
}
x := tenantDTO{ID: newIdentityID(), Name: body.Name}
err := a.pool.QueryRow(r.Context(), `insert into tenants (id,name) values ($1,$2) returning created_at`, x.ID, x.Name).Scan(&x.CreatedAt)
if err != nil {
identityError(w, err)
return
}
writeJSON(w, http.StatusCreated, x)
}
func (a *identityAdmin) createUser(w http.ResponseWriter, r *http.Request) {
var body struct {
TenantID, Name string
Email *string `json:"email"`
}
if !decodeJSON(w, r, &body) {
return
}
body.TenantID = strings.TrimSpace(body.TenantID)
body.Name = strings.TrimSpace(body.Name)
if body.TenantID == "" || body.Name == "" {
badRequest(w, "tenant_id and name are required")
return
}
x := userDTO{ID: newIdentityID(), TenantID: body.TenantID, Name: body.Name, Email: body.Email}
err := a.pool.QueryRow(r.Context(), `insert into tenant_users (id,tenant_id,name,email) values ($1,$2,$3,$4) returning created_at`, x.ID, x.TenantID, x.Name, x.Email).Scan(&x.CreatedAt)
if err != nil {
identityError(w, err)
return
}
writeJSON(w, http.StatusCreated, x)
}
func (a *identityAdmin) createKey(w http.ResponseWriter, r *http.Request) {
var body struct {
TenantID string `json:"tenant_id"`
UserID *string `json:"user_id"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &body) {
return
}
body.TenantID = strings.TrimSpace(body.TenantID)
if body.TenantID == "" {
badRequest(w, "tenant_id is required")
return
}
secret, hash, err := auth.GenerateSecret()
if err != nil {
identityError(w, err)
return
}
x := keyDTO{ID: newIdentityID(), TenantID: body.TenantID, UserID: body.UserID, Description: strings.TrimSpace(body.Description), Source: "managed", Enabled: true}
tx, err := a.pool.Begin(r.Context())
if err != nil {
identityError(w, err)
return
}
defer tx.Rollback(r.Context())
if err = tx.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,$4,'managed',true) returning created_at`, x.ID, x.TenantID, x.UserID, x.Description).Scan(&x.CreatedAt); err == nil {
_, err = tx.Exec(r.Context(), `insert into managed_api_keys (key_id,secret_hash) values ($1,$2)`, x.ID, hash)
}
if err == nil {
err = tx.Commit(r.Context())
}
if err != nil {
identityError(w, err)
return
}
a.keyring.AddManaged(x.ID, hash, true)
a.keyring.AssignTenant(x.ID, x.TenantID)
writeJSON(w, http.StatusCreated, struct {
Key keyDTO `json:"key"`
Secret string `json:"secret"`
}{x, secret})
}
func (a *identityAdmin) updateKey(w http.ResponseWriter, r *http.Request, keyID string) {
var body struct {
TenantID string `json:"tenant_id"`
UserID *string `json:"user_id"`
Description *string `json:"description"`
Enabled *bool `json:"enabled"`
}
if !decodeJSON(w, r, &body) {
return
}
body.TenantID = strings.TrimSpace(body.TenantID)
if body.TenantID == "" {
badRequest(w, "tenant_id is required")
return
}
if !a.keyring.IsConfigured(keyID) {
var exists bool
if err := a.pool.QueryRow(r.Context(), `select exists(select 1 from managed_api_keys where key_id=$1)`, keyID).Scan(&exists); err != nil {
identityError(w, err)
return
}
if !exists {
badRequest(w, "unknown key id")
return
}
}
var x keyDTO
err := a.pool.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,coalesce($4,''),case when $6 then 'configured' else 'managed' end,coalesce($5,true)) on conflict (key_id) do update set tenant_id=excluded.tenant_id,user_id=excluded.user_id,description=coalesce($4,api_key_assignments.description),enabled=coalesce($5,api_key_assignments.enabled) returning key_id,tenant_id,user_id,description,source,enabled,created_at`, keyID, body.TenantID, body.UserID, body.Description, body.Enabled, a.keyring.IsConfigured(keyID)).Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt)
if err != nil {
identityError(w, err)
return
}
a.keyring.AssignTenant(x.ID, x.TenantID)
a.keyring.SetManagedEnabled(x.ID, x.Enabled)
writeJSON(w, http.StatusOK, x)
}
func (a *identityAdmin) adoptLegacy(w http.ResponseWriter, r *http.Request, tenantID string) {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
badRequest(w, "tenant id is required")
return
}
tables := []string{"projects", "thoughts", "stored_files", "learnings", "plans", "chat_histories"}
tx, err := a.pool.Begin(r.Context())
if err != nil {
identityError(w, err)
return
}
defer tx.Rollback(r.Context())
var exists bool
if err = tx.QueryRow(r.Context(), `select exists(select 1 from tenants where id=$1)`, tenantID).Scan(&exists); err == nil && !exists {
badRequest(w, "tenant does not exist")
return
}
for _, table := range tables {
if _, err = tx.Exec(r.Context(), fmt.Sprintf("update %s set tenant_id=$1 where tenant_id is null", table), tenantID); err != nil {
identityError(w, err)
return
}
}
if err = tx.Commit(r.Context()); err != nil {
identityError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func newIdentityID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
badRequest(w, "invalid JSON")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func badRequest(w http.ResponseWriter, message string) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": message})
}
func identityError(w http.ResponseWriter, err error) {
if a, ok := err.(interface{ SQLState() string }); ok && a.SQLState() == "23505" {
badRequest(w, "that value already exists")
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "identity operation failed"})
}
+11
View File
@@ -91,6 +91,14 @@ func Run(ctx context.Context, configPath string) error {
if err != nil {
return err
}
} else {
keyring = auth.NewManagedKeyring()
}
if err := ensureConfiguredTenants(ctx, db.Pool(), keyring); err != nil {
return fmt.Errorf("create configured tenants: %w", err)
}
if err := loadIdentityKeyring(ctx, db.Pool(), keyring); err != nil {
return fmt.Errorf("load identity key assignments: %w", err)
}
tokenStore = auth.NewTokenStore(0)
if len(cfg.Auth.OAuth.Clients) > 0 {
@@ -192,6 +200,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
identityAdmin := newIdentityAdmin(db.Pool(), keyring, logger)
toolSet := mcpserver.ToolSet{
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
@@ -246,6 +255,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
mux.Handle("/api/admin/identity", authMiddleware(identityAdmin.handler()))
mux.Handle("/api/admin/identity/", authMiddleware(identityAdmin.handler()))
mux.HandleFunc("/favicon.ico", serveFavicon)
mux.HandleFunc("/images/project.jpg", serveHomeImage)
mux.HandleFunc("/images/icon.png", serveIcon)
+7 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/uptrace/bunrouter"
"git.warky.dev/wdevs/amcs/internal/store"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware func(http.Handler) http.Handler, logger *slog.Logger) error {
@@ -45,7 +46,12 @@ func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware
rsMount.ServeHTTP(w, r)
return
}
middleware(rsMount).ServeHTTP(w, r)
middleware(http.HandlerFunc(func(w http.ResponseWriter, authenticated *http.Request) {
if tenantID := strings.TrimSpace(authenticated.Header.Get("X-AMCS-Tenant-ID")); tenantID != "" {
authenticated = authenticated.WithContext(tenancy.WithTenantKey(authenticated.Context(), tenantID))
}
rsMount.ServeHTTP(w, authenticated)
})).ServeHTTP(w, r)
})
mux.Handle("/api/rs/", protectedRSMount)
@@ -14,12 +14,14 @@ func resolveSpecModels() []resolveSpecModel {
{schema: "public", entity: "agent_personas", model: generatedmodels.ModelPublicAgentPersonas{}},
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
{schema: "public", entity: "agent_traits", model: generatedmodels.ModelPublicAgentTraits{}},
{schema: "public", entity: "api_key_assignments", model: generatedmodels.ModelPublicAPIKeyAssignments{}},
{schema: "public", entity: "arc_stage_parts", model: generatedmodels.ModelPublicArcStageParts{}},
{schema: "public", entity: "arc_stages", model: generatedmodels.ModelPublicArcStages{}},
{schema: "public", entity: "character_arcs", model: generatedmodels.ModelPublicCharacterArcs{}},
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
{schema: "public", entity: "managed_api_keys", model: generatedmodels.ModelPublicManagedAPIKeys{}},
{schema: "public", entity: "oauth_clients", model: generatedmodels.ModelPublicOauthClients{}},
{schema: "public", entity: "persona_arc", model: generatedmodels.ModelPublicPersonaArc{}},
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
@@ -32,6 +34,8 @@ func resolveSpecModels() []resolveSpecModel {
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
{schema: "public", entity: "tenant_users", model: generatedmodels.ModelPublicTenantUsers{}},
{schema: "public", entity: "tenants", model: generatedmodels.ModelPublicTenants{}},
{schema: "public", entity: "thought_learning_links", model: generatedmodels.ModelPublicThoughtLearningLinks{}},
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
+2 -2
View File
@@ -7,8 +7,8 @@ import (
var (
//go:embed ui/dist
uiFiles embed.FS
uiDistFS fs.FS
uiFiles embed.FS
uiDistFS fs.FS
indexHTML []byte
)