diff --git a/README.md b/README.md index 9751c8d..a54e830 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,60 @@ Role policy: - `admin`: mutating/admin APIs (for example `/api/snapshots/*` mutating endpoints, `/api/event/*`, `/api/import/vm`, `/api/encrypt`, `/api/vcenters/cache/rebuild`). - `admin` implies `viewer` access. +### LDAP group configuration (`auth_group_role_mappings` and `ldap_groups`) +Use full LDAP group DNs for both settings (for example `CN=vctp-admins,OU=Groups,DC=example,DC=com`). + +- `settings.auth_group_role_mappings` is required when `settings.auth_enabled: true`. +- Mapping values must be `viewer` or `admin`. +- A user must resolve to at least one mapped role to log in. +- `settings.ldap_groups` is optional and acts as an additional allowlist gate. +- If `settings.ldap_groups` is empty/omitted, allowlist checking is skipped, but mapped-role resolution is still required. +- DN comparisons are normalized (trimmed + case-insensitive), but using exact directory DNs is still recommended. + +Example (common setup where viewer/admin groups are both mapped and allowlisted): + +```yaml +settings: + auth_enabled: true + auth_mode: required + ldap_bind_address: ldaps://ad01.example.com:636 + ldap_base_dn: DC=example,DC=com + auth_group_role_mappings: + "CN=vctp-viewers,OU=Groups,DC=example,DC=com": viewer + "CN=vctp-admins,OU=Groups,DC=example,DC=com": admin + ldap_groups: + - "CN=vctp-viewers,OU=Groups,DC=example,DC=com" + - "CN=vctp-admins,OU=Groups,DC=example,DC=com" +``` + +Example (`ldap_groups` omitted, only role mapping enforced): + +```yaml +settings: + auth_enabled: true + auth_mode: required + auth_group_role_mappings: + "CN=vctp-viewers,OU=Groups,DC=example,DC=com": viewer + "CN=vctp-admins,OU=Groups,DC=example,DC=com": admin +``` + +Example (`ldap_groups` can be broader, but users still need at least one mapped role): + +```yaml +settings: + auth_enabled: true + auth_mode: required + auth_group_role_mappings: + "CN=vctp-viewers,OU=Groups,DC=example,DC=com": viewer + "CN=vctp-admins,OU=Groups,DC=example,DC=com": admin + ldap_groups: + - "CN=vctp-viewers,OU=Groups,DC=example,DC=com" + - "CN=vctp-admins,OU=Groups,DC=example,DC=com" + - "CN=platform-operators,OU=Groups,DC=example,DC=com" +``` + +Tip: after a successful login, call `GET /api/auth/me` and inspect the returned `groups` claim to copy exact group DN values from your directory. + Public endpoints: - UI pages (`/`, `/vcenters`, `/snapshots/*`, `/vm/trace`) - Swagger UI/docs (`/swagger`, `/swagger/`, `/swagger.json`) @@ -330,6 +384,9 @@ Authentication: - `settings.auth_clock_skew_seconds`: allowed clock skew for token validation. - `settings.auth_group_role_mappings`: map of LDAP group DN -> role (`viewer` or `admin`). - `settings.ldap_groups`: optional allowlist of LDAP group DNs required for login. +- `settings.auth_group_role_mappings` must be non-empty when `settings.auth_enabled: true`. +- A user must belong to at least one mapped group to receive any role and log in. +- `settings.ldap_groups` empty/omitted means no allowlist filter, but mapped-role requirement still applies. - `settings.ldap_bind_address`: LDAP/LDAPS URL used for authentication. - `settings.ldap_base_dn`: LDAP base DN for user/group lookups. - `settings.ldap_trust_cert_file`: optional CA cert file for LDAP TLS. diff --git a/db/helpers.go b/db/helpers.go index 2308abb..f4b8b61 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -3191,13 +3191,7 @@ SELECT THEN 100.0 * agg.gold_hits / agg.samples_present ELSE NULL END AS "Gold" FROM agg -JOIN totals ON totals."Vcenter" = agg."Vcenter" -GROUP BY - agg."InventoryId", agg."Name", agg."Vcenter", agg."VmId", agg."EventKey", agg."CloudId", - agg."Datacenter", agg."Cluster", agg."Folder", - agg."IsTemplate", agg."PoweredOn", agg."SrmPlaceholder", agg."VmUuid", - agg.any_creation, agg.any_deletion, agg.first_present, agg.last_present, - totals.total_samples, totals.max_snapshot; +JOIN totals ON totals."Vcenter" = agg."Vcenter"; `, unionQuery, tableName) return insert, nil } diff --git a/db/helpers_sql_builder_test.go b/db/helpers_sql_builder_test.go new file mode 100644 index 0000000..2d7ccbb --- /dev/null +++ b/db/helpers_sql_builder_test.go @@ -0,0 +1,24 @@ +package db + +import ( + "strings" + "testing" +) + +func TestBuildDailySummaryInsertDoesNotGroupFinalAggJoin(t *testing.T) { + query, err := BuildDailySummaryInsert("inventory_daily_summary_20260101", "SELECT 1") + if err != nil { + t.Fatalf("BuildDailySummaryInsert failed: %v", err) + } + + if !strings.Contains(query, `FROM agg +JOIN totals ON totals."Vcenter" = agg."Vcenter";`) { + t.Fatalf("expected final agg/totals join with terminator, query tail changed unexpectedly") + } + + if strings.Contains(query, `FROM agg +JOIN totals ON totals."Vcenter" = agg."Vcenter" +GROUP BY`) { + t.Fatalf("unexpected final GROUP BY after agg/totals join; this breaks Postgres SQLSTATE 42803") + } +} diff --git a/server/router/router.go b/server/router/router.go index 3bbac1b..a0e408f 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -1,6 +1,7 @@ package router import ( + "encoding/json" "io/fs" "log/slog" "net/http" @@ -104,13 +105,14 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st } else { mux.Handle("/swagger/", middleware.CacheMiddleware(http.StripPrefix("/swagger/", http.FileServer(http.FS(swaggerSub))))) } + swaggerRuntimeSpec := buildRuntimeSwaggerSpec(logger, swaggerSpec, settings.Values.Settings.BindDisableTLS) mux.HandleFunc("/swagger", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/swagger/", http.StatusPermanentRedirect) }) mux.Handle("/swagger.json", middleware.CacheMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(swaggerSpec) + _, _ = w.Write(swaggerRuntimeSpec) }))) // Register pprof handlers only when enabled, and gate them behind admin auth. @@ -124,3 +126,24 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st return middleware.NewLoggingMiddleware(logger, mux) } + +func buildRuntimeSwaggerSpec(logger *slog.Logger, baseSpec []byte, bindDisableTLS bool) []byte { + scheme := "https" + if bindDisableTLS { + scheme = "http" + } + + var parsed map[string]any + if err := json.Unmarshal(baseSpec, &parsed); err != nil { + logger.Warn("failed to parse embedded swagger spec; serving original", "error", err) + return baseSpec + } + parsed["schemes"] = []string{scheme} + + updated, err := json.Marshal(parsed) + if err != nil { + logger.Warn("failed to render runtime swagger spec; serving original", "error", err) + return baseSpec + } + return updated +} diff --git a/server/router/static_assets_test.go b/server/router/static_assets_test.go index 517c4a9..1ef5d8a 100644 --- a/server/router/static_assets_test.go +++ b/server/router/static_assets_test.go @@ -1,9 +1,11 @@ package router import ( + "encoding/json" "net/http" "net/http/httptest" "regexp" + "reflect" "strings" "testing" "vctp/version" @@ -112,3 +114,51 @@ func TestStaticResourcesAreCacheableInReleaseMode(t *testing.T) { }) } } + +func TestSwaggerJSONDefaultsToHTTPSWhenTLSEnabled(t *testing.T) { + cfg := testRouterSettings(t, false) + cfg.Values.Settings.BindDisableTLS = false + app := testRouter(t, cfg) + + req := httptest.NewRequest(http.MethodGet, "/swagger.json", nil) + rr := httptest.NewRecorder() + app.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var spec struct { + Schemes []string `json:"schemes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &spec); err != nil { + t.Fatalf("failed to decode swagger spec: %v", err) + } + if !reflect.DeepEqual(spec.Schemes, []string{"https"}) { + t.Fatalf("unexpected schemes: got %v want %v", spec.Schemes, []string{"https"}) + } +} + +func TestSwaggerJSONDefaultsToHTTPWhenTLSDisabled(t *testing.T) { + cfg := testRouterSettings(t, false) + cfg.Values.Settings.BindDisableTLS = true + app := testRouter(t, cfg) + + req := httptest.NewRequest(http.MethodGet, "/swagger.json", nil) + rr := httptest.NewRecorder() + app.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var spec struct { + Schemes []string `json:"schemes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &spec); err != nil { + t.Fatalf("failed to decode swagger spec: %v", err) + } + if !reflect.DeepEqual(spec.Schemes, []string{"http"}) { + t.Fatalf("unexpected schemes: got %v want %v", spec.Schemes, []string{"http"}) + } +}