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"}) }