d7c0205c50
CI / build-and-test (push) Successful in 2m46s
* Introduce Superadmin field in OAuthClient configuration * Implement IsSuperadmin method in OAuthRegistry * Update identityAdmin to check superadmin status via OAuthRegistry * Add tests for OAuthRegistry superadmin functionality
373 lines
12 KiB
Go
373 lines
12 KiB
Go
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
|
|
oauthRegistry *auth.OAuthRegistry
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func newIdentityAdmin(pool *pgxpool.Pool, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, logger *slog.Logger) *identityAdmin {
|
|
return &identityAdmin{pool: pool, keyring: keyring, oauthRegistry: oauthRegistry, logger: logger}
|
|
}
|
|
|
|
func (a *identityAdmin) isSuperadmin(keyID string) bool {
|
|
if a.keyring != nil && a.keyring.IsSuperadmin(keyID) {
|
|
return true
|
|
}
|
|
return a.oauthRegistry != nil && a.oauthRegistry.IsSuperadmin(keyID)
|
|
}
|
|
|
|
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.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"})
|
|
}
|