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:
2026-03-31 23:47:53 -07:00
parent 4430ce38a4
commit df7773229c
24 changed files with 2284 additions and 217 deletions

View File

@@ -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
}