mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
925523e06e
Audit 2026-05-10 Fix 13 Phase B — close MED-9. MED-4/5/6/7 deferred to v3.
MED-9: ship the OIDCProvider.Enabled boolean. Pre-fix, the only way
to take a provider offline during an incident was DELETE, which
breaks active user_oidc_provider FK references and orphans any
session that minted under the provider. Post-fix:
- Migration 000042 adds enabled BOOLEAN NOT NULL DEFAULT TRUE.
Default-true means existing pre-migration rows are all enabled
post-deploy; no breaking-change window.
- internal/auth/oidc/domain/types.go::OIDCProvider.Enabled ships
the domain field with JSON tag 'enabled'.
- Repository read/write paths (List, Get, GetByName, Create, Update)
all carry the column.
- internal/auth/oidc/service.go::HandleAuthRequest rejects with
the new ErrProviderDisabled sentinel when cfgRow.Enabled=false.
- cmd/server/main.go::oidcProvidersListAdapter.List filters
disabled providers before constructing OIDCProviderInfo so the
LoginPage's 'Sign in with X' buttons never render for offline
IdPs.
- Defense-in-depth: the ErrProviderDisabled service-layer check
is the guard for direct API / MCP / CLI callers that bypass the
GUI.
Regression test: internal/auth/oidc/provider_enabled_test.go warms
the entry cache via a successful HandleAuthRequest, flips
cfgRow.Enabled=false on the cached entry, then asserts the next call
returns ErrProviderDisabled (errors.Is). Test fixtures (newValidProvider,
makeProvider) updated to set Enabled: true so existing tests stay
green.
Operators can toggle Enabled today via the existing PUT
/api/v1/auth/oidc/providers/{id} body field. A dedicated GUI
toggle on OIDCProviderDetailPage and a single-purpose PUT-just-enabled
endpoint are deferred to the v3 GUI-polish bundle — the load-bearing
wire is in place now.
MED-4 (GUI advanced fields on edit), MED-5 (POST .../test endpoint
+ button), MED-6 (JWKS auto-refresh on cache-miss), MED-7 (JWKS
health endpoint + GUI panel): DEFERRED to v3 with explicit
annotations in the audit doc. Workarounds: MED-4 fields are
PUT-editable via curl/MCP; MED-5 → call refresh post-create;
MED-6 → call refresh manually on key rotation.
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4, MED-5, MED-6,
MED-7, MED-9
Spec: cowork/auth-bundles-fixes-2026-05-10/13-med-bundle.md Phase B
368 lines
11 KiB
Go
368 lines
11 KiB
Go
package postgres_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
"github.com/certctl-io/certctl/internal/repository/postgres"
|
|
)
|
|
|
|
// =============================================================================
|
|
// OIDCProviderRepository tests (Auth Bundle 2 Phase 2)
|
|
//
|
|
// Schema-per-test isolation via getTestDB().freshSchema(t). Run with:
|
|
//
|
|
// go test -count=1 ./internal/repository/postgres/...
|
|
//
|
|
// (omit -short; testing.Short() skips all integration tests.)
|
|
// =============================================================================
|
|
|
|
func newValidProvider(suffix string) *oidcdomain.OIDCProvider {
|
|
return &oidcdomain.OIDCProvider{
|
|
ID: "op-" + suffix,
|
|
TenantID: "t-default",
|
|
Name: "Provider " + suffix,
|
|
IssuerURL: "https://idp." + suffix + ".example.com",
|
|
ClientID: "certctl",
|
|
ClientSecretEncrypted: []byte{0x02, 0x00, 0x01, 0x02, 0x03},
|
|
RedirectURI: "https://certctl.example.com/auth/oidc/callback",
|
|
GroupsClaimPath: "groups",
|
|
GroupsClaimFormat: "string-array",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
AllowedEmailDomains: []string{},
|
|
IATWindowSeconds: 300,
|
|
JWKSCacheTTLSeconds: 3600,
|
|
Enabled: true, // MED-9: default-on for test fixtures
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_CreateAndGet(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("a")
|
|
if err := repo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.Name != p.Name {
|
|
t.Errorf("Name roundtrip: got %q, want %q", got.Name, p.Name)
|
|
}
|
|
if got.IssuerURL != p.IssuerURL {
|
|
t.Errorf("IssuerURL roundtrip mismatch")
|
|
}
|
|
// Defaults from the migration kicked in for any unset bool / array.
|
|
if got.FetchUserinfo != false {
|
|
t.Errorf("FetchUserinfo default = %v; want false", got.FetchUserinfo)
|
|
}
|
|
if len(got.Scopes) != 3 {
|
|
t.Errorf("Scopes roundtrip count = %d; want 3", len(got.Scopes))
|
|
}
|
|
// Defense: client_secret_encrypted column must NOT contain plaintext.
|
|
// Since we wrote a v2 magic-byte stub, the byte stream comes back as-is.
|
|
if len(got.ClientSecretEncrypted) == 0 {
|
|
t.Errorf("ClientSecretEncrypted lost on roundtrip")
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_GetNotFound(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
_, err := repo.Get(ctx, "op-nonexistent")
|
|
if !errors.Is(err, repository.ErrOIDCProviderNotFound) {
|
|
t.Errorf("err = %v; want ErrOIDCProviderNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_DuplicateName(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p1 := newValidProvider("dup1")
|
|
if err := repo.Create(ctx, p1); err != nil {
|
|
t.Fatalf("Create p1: %v", err)
|
|
}
|
|
|
|
p2 := newValidProvider("dup2")
|
|
p2.Name = p1.Name // collision on (tenant_id, name)
|
|
err := repo.Create(ctx, p2)
|
|
if !errors.Is(err, repository.ErrOIDCProviderDuplicateName) {
|
|
t.Errorf("Create with duplicate name err = %v; want ErrOIDCProviderDuplicateName", err)
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_List(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
for _, suf := range []string{"x", "y", "z"} {
|
|
if err := repo.Create(ctx, newValidProvider(suf)); err != nil {
|
|
t.Fatalf("Create %q: %v", suf, err)
|
|
}
|
|
}
|
|
|
|
out, err := repo.List(ctx, "t-default")
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(out) != 3 {
|
|
t.Errorf("List count = %d; want 3", len(out))
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_Update(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("upd")
|
|
if err := repo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
p.Name = "Renamed"
|
|
p.FetchUserinfo = true
|
|
if err := repo.Update(ctx, p); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
got, err := repo.Get(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get post-update: %v", err)
|
|
}
|
|
if got.Name != "Renamed" {
|
|
t.Errorf("Update did not persist Name; got %q", got.Name)
|
|
}
|
|
if !got.FetchUserinfo {
|
|
t.Errorf("Update did not persist FetchUserinfo")
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_DeleteNotFound(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
err := repo.Delete(ctx, "op-nonexistent")
|
|
if !errors.Is(err, repository.ErrOIDCProviderNotFound) {
|
|
t.Errorf("err = %v; want ErrOIDCProviderNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestOIDCProviderRepository_DeleteSucceedsWhenNoUsersReference(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
repo := postgres.NewOIDCProviderRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("del")
|
|
if err := repo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if err := repo.Delete(ctx, p.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
_, err := repo.Get(ctx, p.ID)
|
|
if !errors.Is(err, repository.ErrOIDCProviderNotFound) {
|
|
t.Errorf("post-delete Get err = %v; want ErrOIDCProviderNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestOIDCProviderRepository_DeleteRefusedWhenUsersReference pins the
|
|
// FK ON DELETE RESTRICT translation. With at least one users row
|
|
// referencing the provider, Delete must return ErrOIDCProviderInUse.
|
|
func TestOIDCProviderRepository_DeleteRefusedWhenUsersReference(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
providerRepo := postgres.NewOIDCProviderRepository(db)
|
|
userRepo := postgres.NewUserRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("inuse")
|
|
if err := providerRepo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create provider: %v", err)
|
|
}
|
|
u := &struct{ ID string }{ID: "u-test"}
|
|
_ = u
|
|
user := newValidUser("inuse", p.ID)
|
|
if err := userRepo.Create(ctx, user); err != nil {
|
|
t.Fatalf("Create user: %v", err)
|
|
}
|
|
|
|
err := providerRepo.Delete(ctx, p.ID)
|
|
if !errors.Is(err, repository.ErrOIDCProviderInUse) {
|
|
t.Errorf("Delete with referencing user err = %v; want ErrOIDCProviderInUse", err)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GroupRoleMappingRepository
|
|
// =============================================================================
|
|
|
|
func TestGroupRoleMappingRepository_AddListMap(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
providerRepo := postgres.NewOIDCProviderRepository(db)
|
|
mappingRepo := postgres.NewGroupRoleMappingRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("grm")
|
|
if err := providerRepo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create provider: %v", err)
|
|
}
|
|
|
|
mappings := []*oidcdomain.GroupRoleMapping{
|
|
{ID: "grm-1", TenantID: "t-default", ProviderID: p.ID, GroupName: "engineers", RoleID: "r-operator"},
|
|
{ID: "grm-2", TenantID: "t-default", ProviderID: p.ID, GroupName: "platform-admins", RoleID: "r-admin"},
|
|
{ID: "grm-3", TenantID: "t-default", ProviderID: p.ID, GroupName: "compliance", RoleID: "r-auditor"},
|
|
}
|
|
for _, m := range mappings {
|
|
if err := mappingRepo.Add(ctx, m); err != nil {
|
|
t.Fatalf("Add %s: %v", m.GroupName, err)
|
|
}
|
|
}
|
|
|
|
listed, err := mappingRepo.ListByProvider(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListByProvider: %v", err)
|
|
}
|
|
if len(listed) != 3 {
|
|
t.Errorf("ListByProvider count = %d; want 3", len(listed))
|
|
}
|
|
|
|
// Map: user has groups [engineers, marketing]. Marketing has no
|
|
// mapping; only engineers maps to r-operator.
|
|
roleIDs, err := mappingRepo.Map(ctx, p.ID, []string{"engineers", "marketing"})
|
|
if err != nil {
|
|
t.Fatalf("Map: %v", err)
|
|
}
|
|
if len(roleIDs) != 1 || roleIDs[0] != "r-operator" {
|
|
t.Errorf("Map(engineers, marketing) = %v; want [r-operator]", roleIDs)
|
|
}
|
|
|
|
// Map: user has groups [engineers, platform-admins]. Both map.
|
|
roleIDs, err = mappingRepo.Map(ctx, p.ID, []string{"engineers", "platform-admins"})
|
|
if err != nil {
|
|
t.Fatalf("Map (multi): %v", err)
|
|
}
|
|
if len(roleIDs) != 2 {
|
|
t.Errorf("Map(engineers, platform-admins) count = %d; want 2", len(roleIDs))
|
|
}
|
|
|
|
// Map empty groups: empty result, no error (Phase 3 fail-closes).
|
|
roleIDs, err = mappingRepo.Map(ctx, p.ID, nil)
|
|
if err != nil {
|
|
t.Fatalf("Map(nil): %v", err)
|
|
}
|
|
if len(roleIDs) != 0 {
|
|
t.Errorf("Map(nil) returned %d roles; want 0", len(roleIDs))
|
|
}
|
|
}
|
|
|
|
func TestGroupRoleMappingRepository_DuplicateRejected(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
providerRepo := postgres.NewOIDCProviderRepository(db)
|
|
mappingRepo := postgres.NewGroupRoleMappingRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("dup")
|
|
if err := providerRepo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create provider: %v", err)
|
|
}
|
|
m := &oidcdomain.GroupRoleMapping{
|
|
ID: "grm-dup-1", TenantID: "t-default", ProviderID: p.ID,
|
|
GroupName: "engineers", RoleID: "r-operator",
|
|
}
|
|
if err := mappingRepo.Add(ctx, m); err != nil {
|
|
t.Fatalf("Add first: %v", err)
|
|
}
|
|
m2 := &oidcdomain.GroupRoleMapping{
|
|
ID: "grm-dup-2", TenantID: "t-default", ProviderID: p.ID,
|
|
GroupName: "engineers", RoleID: "r-operator",
|
|
}
|
|
err := mappingRepo.Add(ctx, m2)
|
|
if !errors.Is(err, repository.ErrGroupRoleMappingDuplicate) {
|
|
t.Errorf("Add duplicate err = %v; want ErrGroupRoleMappingDuplicate", err)
|
|
}
|
|
}
|
|
|
|
func TestGroupRoleMappingRepository_ProviderDeleteCascades(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("integration test in short mode")
|
|
}
|
|
db := getTestDB(t).freshSchema(t)
|
|
providerRepo := postgres.NewOIDCProviderRepository(db)
|
|
mappingRepo := postgres.NewGroupRoleMappingRepository(db)
|
|
ctx := context.Background()
|
|
|
|
p := newValidProvider("cascade")
|
|
if err := providerRepo.Create(ctx, p); err != nil {
|
|
t.Fatalf("Create provider: %v", err)
|
|
}
|
|
for i, group := range []string{"a", "b", "c"} {
|
|
m := &oidcdomain.GroupRoleMapping{
|
|
ID: "grm-cas-" + string(rune('a'+i)), TenantID: "t-default",
|
|
ProviderID: p.ID, GroupName: group, RoleID: "r-viewer",
|
|
}
|
|
if err := mappingRepo.Add(ctx, m); err != nil {
|
|
t.Fatalf("Add %s: %v", group, err)
|
|
}
|
|
}
|
|
|
|
// Delete provider: ON DELETE CASCADE on group_role_mappings.provider_id
|
|
// should drop the 3 mappings too.
|
|
if err := providerRepo.Delete(ctx, p.ID); err != nil {
|
|
t.Fatalf("Delete provider: %v", err)
|
|
}
|
|
listed, err := mappingRepo.ListByProvider(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListByProvider post-cascade: %v", err)
|
|
}
|
|
if len(listed) != 0 {
|
|
t.Errorf("CASCADE failed; %d mappings remain", len(listed))
|
|
}
|
|
}
|
|
|
|
// quiet unused-import keepalives so single-test runs don't drop them.
|
|
var _ = time.Now
|