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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user