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>
This commit is contained in:
@@ -54,13 +54,17 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the registered SSO client for policy context.
|
||||
client := s.cfg.SSOClient(req.ClientID)
|
||||
if client == nil {
|
||||
// Look up the registered SSO client from the database for policy context.
|
||||
client, clientErr := s.db.GetSSOClient(req.ClientID)
|
||||
if clientErr != nil {
|
||||
// Should not happen if the authorize endpoint validated, but defend in depth.
|
||||
middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code")
|
||||
return
|
||||
}
|
||||
if !client.Enabled {
|
||||
middleware.WriteError(w, http.StatusForbidden, "SSO client is disabled", "client_disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Load account.
|
||||
acct, err := s.db.GetAccountByID(ac.AccountID)
|
||||
@@ -82,7 +86,7 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Policy evaluation with the SSO client's service_name and tags.
|
||||
// Policy evaluation: client_id serves as both identifier and service_name.
|
||||
{
|
||||
input := policy.PolicyInput{
|
||||
Subject: acct.UUID,
|
||||
@@ -90,13 +94,13 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
|
||||
Roles: roles,
|
||||
Action: policy.ActionLogin,
|
||||
Resource: policy.Resource{
|
||||
ServiceName: client.ServiceName,
|
||||
ServiceName: client.ClientID,
|
||||
Tags: client.Tags,
|
||||
},
|
||||
}
|
||||
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
|
||||
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
|
||||
audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso"))
|
||||
audit.JSON("reason", "policy_deny", "service_name", client.ClientID, "via", "sso"))
|
||||
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
|
||||
return
|
||||
}
|
||||
|
||||
175
internal/server/handlers_sso_clients.go
Normal file
175
internal/server/handlers_sso_clients.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
@@ -373,6 +373,18 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("DELETE /v1/policy/rules/{id}",
|
||||
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||
|
||||
// SSO client management (admin-only).
|
||||
mux.Handle("GET /v1/sso/clients",
|
||||
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleListSSOClients)))
|
||||
mux.Handle("POST /v1/sso/clients",
|
||||
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleCreateSSOClient)))
|
||||
mux.Handle("GET /v1/sso/clients/{clientId}",
|
||||
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleGetSSOClient)))
|
||||
mux.Handle("PATCH /v1/sso/clients/{clientId}",
|
||||
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleUpdateSSOClient)))
|
||||
mux.Handle("DELETE /v1/sso/clients/{clientId}",
|
||||
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleDeleteSSOClient)))
|
||||
|
||||
// UI routes (HTMX-based management frontend).
|
||||
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user