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

@@ -88,6 +88,7 @@ func main() {
root.AddCommand(pgcredsCmd())
root.AddCommand(policyCmd())
root.AddCommand(tagCmd())
root.AddCommand(ssoCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
@@ -956,6 +957,160 @@ func tagSetCmd() *cobra.Command {
return cmd
}
// ---- SSO client commands ----
func ssoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sso",
Short: "SSO client management commands",
}
cmd.AddCommand(ssoListCmd())
cmd.AddCommand(ssoCreateCmd())
cmd.AddCommand(ssoGetCmd())
cmd.AddCommand(ssoUpdateCmd())
cmd.AddCommand(ssoDeleteCmd())
return cmd
}
func ssoListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all SSO clients",
Run: func(cmd *cobra.Command, args []string) {
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/sso/clients", nil, &result)
printJSON(result)
},
}
}
func ssoCreateCmd() *cobra.Command {
var clientID, redirectURI, tagsFlag string
cmd := &cobra.Command{
Use: "create",
Short: "Register a new SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso create: --client-id is required")
}
if redirectURI == "" {
fatalf("sso create: --redirect-uri is required")
}
c := newController()
body := map[string]interface{}{
"client_id": clientID,
"redirect_uri": redirectURI,
}
if tagsFlag != "" {
tags := []string{}
for _, t := range strings.Split(tagsFlag, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
body["tags"] = tags
}
var result json.RawMessage
c.doRequest("POST", "/v1/sso/clients", body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier / service name (required)")
cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "callback URL (https:// required)")
cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags")
return cmd
}
func ssoGetCmd() *cobra.Command {
var clientID string
cmd := &cobra.Command{
Use: "get",
Short: "Get an SSO client by client-id",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso get: --client-id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/sso/clients/"+clientID, nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
return cmd
}
func ssoUpdateCmd() *cobra.Command {
var clientID, redirectURI, tagsFlag, enabledFlag string
cmd := &cobra.Command{
Use: "update",
Short: "Update an SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso update: --client-id is required")
}
c := newController()
body := map[string]interface{}{}
if cmd.Flags().Changed("redirect-uri") {
body["redirect_uri"] = redirectURI
}
if cmd.Flags().Changed("tags") {
tags := []string{}
if tagsFlag != "" {
for _, t := range strings.Split(tagsFlag, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
}
body["tags"] = tags
}
if cmd.Flags().Changed("enabled") {
body["enabled"] = enabledFlag == "true"
}
var result json.RawMessage
c.doRequest("PATCH", "/v1/sso/clients/"+clientID, body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "new callback URL")
cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags (empty clears)")
cmd.Flags().StringVar(&enabledFlag, "enabled", "", "true or false")
return cmd
}
func ssoDeleteCmd() *cobra.Command {
var clientID string
cmd := &cobra.Command{
Use: "delete",
Short: "Delete an SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso delete: --client-id is required")
}
c := newController()
c.doRequest("DELETE", "/v1/sso/clients/"+clientID, nil, nil)
fmt.Printf("SSO client %q deleted\n", clientID)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
return cmd
}
// ---- HTTP helpers ----
// doRequest performs an authenticated JSON HTTP request. If result is non-nil,