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

@@ -244,153 +244,6 @@ func TestTrustedProxyValidation(t *testing.T) {
}
}
func TestSSOClientValidation(t *testing.T) {
tests := []struct {
name string
extra string
wantErr bool
}{
{
name: "valid single client",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
tags = ["env:restricted"]
`,
wantErr: false,
},
{
name: "valid multiple clients",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
[[sso.clients]]
client_id = "mcat"
redirect_uri = "https://mcat.example.com/sso/callback"
service_name = "mcat"
`,
wantErr: false,
},
{
name: "missing client_id",
extra: `
[[sso.clients]]
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "missing redirect_uri",
extra: `
[[sso.clients]]
client_id = "mcr"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "http redirect_uri rejected",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "http://mcr.example.com/sso/callback"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "missing service_name",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
`,
wantErr: true,
},
{
name: "duplicate client_id",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://other.example.com/sso/callback"
service_name = "mcr2"
`,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := writeTempConfig(t, validConfig()+tc.extra)
_, err := Load(path)
if tc.wantErr && err == nil {
t.Error("expected validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestSSOClientLookup(t *testing.T) {
path := writeTempConfig(t, validConfig()+`
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
tags = ["env:restricted"]
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cl := cfg.SSOClient("mcr")
if cl == nil {
t.Fatal("SSOClient(mcr) returned nil")
}
if cl.RedirectURI != "https://mcr.example.com/sso/callback" {
t.Errorf("RedirectURI = %q", cl.RedirectURI)
}
if cl.ServiceName != "mcr" {
t.Errorf("ServiceName = %q", cl.ServiceName)
}
if len(cl.Tags) != 1 || cl.Tags[0] != "env:restricted" {
t.Errorf("Tags = %v", cl.Tags)
}
if cfg.SSOClient("nonexistent") != nil {
t.Error("SSOClient(nonexistent) should return nil")
}
if !cfg.SSOEnabled() {
t.Error("SSOEnabled() should return true")
}
}
func TestSSODisabledByDefault(t *testing.T) {
path := writeTempConfig(t, validConfig())
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SSOEnabled() {
t.Error("SSOEnabled() should return false with no clients")
}
}
func TestDurationParsing(t *testing.T) {
var d duration
if err := d.UnmarshalText([]byte("1h30m")); err != nil {