feat(ui): add identity management for tenants and users
CI / build-and-test (push) Successful in 1m47s
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:
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user