@@ -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`: mutating/admin APIs (for example `/api/snapshots/*` mutating endpoints, `/api/event/*`, `/api/import/vm`, `/api/encrypt`, `/api/vcenters/cache/rebuild`).
|
||||||
- `admin` implies `viewer` access.
|
- `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:
|
Public endpoints:
|
||||||
- UI pages (`/`, `/vcenters`, `/snapshots/*`, `/vm/trace`)
|
- UI pages (`/`, `/vcenters`, `/snapshots/*`, `/vm/trace`)
|
||||||
- Swagger UI/docs (`/swagger`, `/swagger/`, `/swagger.json`)
|
- 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_clock_skew_seconds`: allowed clock skew for token validation.
|
||||||
- `settings.auth_group_role_mappings`: map of LDAP group DN -> role (`viewer` or `admin`).
|
- `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.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_bind_address`: LDAP/LDAPS URL used for authentication.
|
||||||
- `settings.ldap_base_dn`: LDAP base DN for user/group lookups.
|
- `settings.ldap_base_dn`: LDAP base DN for user/group lookups.
|
||||||
- `settings.ldap_trust_cert_file`: optional CA cert file for LDAP TLS.
|
- `settings.ldap_trust_cert_file`: optional CA cert file for LDAP TLS.
|
||||||
|
|||||||
+1
-7
@@ -3191,13 +3191,7 @@ SELECT
|
|||||||
THEN 100.0 * agg.gold_hits / agg.samples_present
|
THEN 100.0 * agg.gold_hits / agg.samples_present
|
||||||
ELSE NULL END AS "Gold"
|
ELSE NULL END AS "Gold"
|
||||||
FROM agg
|
FROM agg
|
||||||
JOIN totals ON totals."Vcenter" = agg."Vcenter"
|
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;
|
|
||||||
`, unionQuery, tableName)
|
`, unionQuery, tableName)
|
||||||
return insert, nil
|
return insert, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-1
@@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -104,13 +105,14 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st
|
|||||||
} else {
|
} else {
|
||||||
mux.Handle("/swagger/", middleware.CacheMiddleware(http.StripPrefix("/swagger/", http.FileServer(http.FS(swaggerSub)))))
|
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) {
|
mux.HandleFunc("/swagger", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/swagger/", http.StatusPermanentRedirect)
|
http.Redirect(w, r, "/swagger/", http.StatusPermanentRedirect)
|
||||||
})
|
})
|
||||||
mux.Handle("/swagger.json", middleware.CacheMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/swagger.json", middleware.CacheMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(swaggerSpec)
|
_, _ = w.Write(swaggerRuntimeSpec)
|
||||||
})))
|
})))
|
||||||
|
|
||||||
// Register pprof handlers only when enabled, and gate them behind admin auth.
|
// 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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"vctp/version"
|
"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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user