Files
mcias/internal/server/handlers_sso_clients.go
Kyle Isom df7773229c Move SSO clients from config to database
- Add sso_clients table (migration 000010) with client_id, redirect_uri,
  tags (JSON), enabled flag, and audit timestamps
- Add SSOClient model struct and audit events
- Implement DB CRUD with 10 unit tests
- Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated)
- Add gRPC SSOClientService with 5 RPCs (admin-only)
- Add mciasctl sso list/create/get/update/delete commands
- Add web UI admin page at /sso-clients with HTMX create/toggle/delete
- Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB
- Remove SSOConfig, SSOClient struct, lookup methods from config
- Simplify: client_id = service_name for policy evaluation

Security:
- SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC)
- redirect_uri must use https:// (validated at DB layer)
- Disabled clients are rejected at both authorize and token exchange
- All mutations write audit events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:47:53 -07:00

176 lines
4.8 KiB
Go

package server
import (
"errors"
"fmt"
"net/http"
"time"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientResponse struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func ssoClientToResponse(c *model.SSOClient) ssoClientResponse {
return ssoClientResponse{
ClientID: c.ClientID,
RedirectURI: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}
func (s *Server) handleListSSOClients(w http.ResponseWriter, r *http.Request) {
clients, err := s.db.ListSSOClients()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
resp := make([]ssoClientResponse, 0, len(clients))
for _, c := range clients {
resp = append(resp, ssoClientToResponse(c))
}
writeJSON(w, http.StatusOK, resp)
}
type createSSOClientRequest struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
}
func (s *Server) handleCreateSSOClient(w http.ResponseWriter, r *http.Request) {
var req createSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
if req.ClientID == "" {
middleware.WriteError(w, http.StatusBadRequest, "client_id is required", "bad_request")
return
}
if req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "redirect_uri is required", "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var createdBy *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := s.db.CreateSSOClient(req.ClientID, req.RedirectURI, req.Tags, createdBy)
if err != nil {
s.logger.Error("create SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
s.writeAudit(r, model.EventSSOClientCreated, createdBy, nil,
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
writeJSON(w, http.StatusCreated, ssoClientToResponse(c))
}
func (s *Server) handleGetSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
c, err := s.db.GetSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
type updateSSOClientRequest struct {
RedirectURI *string `json:"redirect_uri,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (s *Server) handleUpdateSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
var req updateSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
err := s.db.UpdateSSOClient(clientID, req.RedirectURI, req.Tags, req.Enabled)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
s.logger.Error("update SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientUpdated, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
c, err := s.db.GetSSOClient(clientID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
func (s *Server) handleDeleteSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
err := s.db.DeleteSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientDeleted, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
w.WriteHeader(http.StatusNoContent)
}