Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 174 additions & 7 deletions controlplane/admin/models_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package admin
import (
"net/http"
"reflect"
"regexp"
"sort"
"strings"

"github.com/gin-gonic/gin"
Expand All @@ -24,12 +26,20 @@ const (
modelGroupTenants modelGroup = "Tenants"
modelGroupRuntime modelGroup = "Runtime"
modelGroupAdmin modelGroup = "Admin"
// Other holds AUTO-DISCOVERED tables: everything information_schema shows
// in the config + runtime schemas that no typed descriptor covers. New
// tables (migrations, AutoMigrated operational state) appear here with no
// registry edit — the explorer is a database explorer, not a curated list.
modelGroupOther modelGroup = "Other"
)

// modelDescriptor describes one browsable config-store table for the generic
// models explorer. The descriptors are the single source of truth the sidebar,
// the listing endpoint, and the column derivation all read from — adding a new
// config-store model is one entry here.
// models explorer. A typed descriptor gives a table PRECISE redaction (fields
// tagged json:"-" vanish) plus a human label/group; every table WITHOUT a
// descriptor is still browsable via information_schema auto-discovery (group
// "Other"), where credential-shaped column values are redacted by name
// pattern. Add a descriptor when a table needs exact redaction or a curated
// spot in the sidebar — nothing breaks if you don't.
type modelDescriptor struct {
// Key is the URL slug and stable identifier (e.g. "orgs").
Key string
Expand All @@ -55,10 +65,9 @@ type modelDescriptor struct {
elem reflect.Type
}

// modelDescriptors returns the ordered registry of browsable models. Order is
// the sidebar order. Every persisted configstore model that an operator might
// want to inspect belongs here; if you add a model in configstore/models.go,
// add it here too (and assert it in models_api_test.go).
// modelDescriptors returns the ordered registry of typed models. Order is the
// sidebar order for the curated groups; tables not listed here surface
// automatically under "Other" via information_schema discovery.
func modelDescriptors() []modelDescriptor {
mk := func(key, label string, group modelGroup, runtime bool, sample any) modelDescriptor {
// table name from the model's TableName() via the gorm Tabler contract.
Expand Down Expand Up @@ -103,6 +112,92 @@ type modelsHandler struct {
store *configstore.ConfigStore
}

// autoKeyPrefix namespaces the keys of auto-discovered tables ("auto:<schema>.<table>").
const autoKeyPrefix = "auto:"

// sensitiveColumnRe redacts VALUES of auto-discovered columns whose name looks
// credential-shaped. The typed descriptors above redact precisely via json
// tags; auto-discovered tables have no type to consult, so err toward
// redaction — a bcrypt hash or ciphertext leaking is fatal, an over-redacted
// secret NAME is a shrug. (Matches e.g. password, password_aws_secret,
// ciphertext, token, nonce, credential.)
var sensitiveColumnRe = regexp.MustCompile(`(?i)(password|secret|ciphertext|token|nonce|credential)`)

// autoTable is one information_schema-discovered table.
type autoTable struct {
Schema string
Name string
}

func (a autoTable) key() string { return autoKeyPrefix + a.Schema + "." + a.Name }
func (a autoTable) label() string { return a.Name }

// discoverTables lists every base table in the config schema (current_schema)
// and the CP runtime schema that is NOT already covered by a typed descriptor.
// Reading information_schema fresh per request keeps the explorer honest: a
// table created by a migration or AutoMigrate after boot appears immediately.
func (h *modelsHandler) discoverTables() ([]autoTable, error) {
covered := map[string]struct{}{}
for _, d := range modelDescriptors() {
covered[d.Table] = struct{}{}
}
runtimeSchema := h.store.RuntimeSchema()

type row struct {
TableSchema string
TableName string
}
var rows []row
err := h.store.DB().Raw(`
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND table_schema IN (current_schema(), ?)
ORDER BY table_schema, table_name`, runtimeSchema).Scan(&rows).Error
if err != nil {
return nil, err
}
var out []autoTable
for _, r := range rows {
if _, ok := covered[r.TableName]; ok {
continue
}
out = append(out, autoTable{Schema: r.TableSchema, Name: r.TableName})
}
sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
return out, nil
}

// resolveAutoTable validates an auto key against a FRESH discovery — the only
// table names ever interpolated into SQL are ones information_schema just
// returned, so a crafted key can't smuggle SQL.
func (h *modelsHandler) resolveAutoTable(key string) (autoTable, bool) {
if !strings.HasPrefix(key, autoKeyPrefix) {
return autoTable{}, false
}
tables, err := h.discoverTables()
if err != nil {
return autoTable{}, false
}
for _, t := range tables {
if t.key() == key {
return t, true
}
}
return autoTable{}, false
}

// autoColumns returns the table's column names in ordinal order.
func (h *modelsHandler) autoColumns(t autoTable) ([]string, error) {
var cols []string
err := h.store.DB().Raw(`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position`, t.Schema, t.Name).Scan(&cols).Error
return cols, err
}

// registerModelsAPI registers the read-only generic models explorer used by the
// admin dashboard's models browser.
func registerModelsAPI(r *gin.RouterGroup, store *configstore.ConfigStore) {
Expand Down Expand Up @@ -146,6 +241,21 @@ func (h *modelsHandler) listModels(c *gin.Context) {
Count: count,
})
}
autoTables, err := h.discoverTables()
if err == nil {
for _, t := range autoTables {
var count int64
if err := db.Table(t.Schema + "." + t.Name).Count(&count).Error; err != nil {
count = -1
}
out = append(out, modelSummary{
Key: t.key(),
Label: t.label(),
Group: string(modelGroupOther),
Count: count,
})
}
}
c.JSON(http.StatusOK, gin.H{"models": out})
}

Expand All @@ -172,6 +282,10 @@ func (h *modelsHandler) getModel(c *gin.Context) {
}
}
if desc == nil {
if t, ok := h.resolveAutoTable(key); ok {
h.getAutoTable(c, t)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "unknown model: " + key})
return
}
Expand Down Expand Up @@ -240,3 +354,56 @@ func jsonFieldOrder(t reflect.Type) []string {
}
return cols
}

// getAutoTable lists an auto-discovered table: columns from
// information_schema, rows as generic maps, values of credential-shaped
// columns redacted (see sensitiveColumnRe — auto tables have no typed model
// whose json tags could redact precisely).
func (h *modelsHandler) getAutoTable(c *gin.Context, t autoTable) {
db := h.store.DB()
table := t.Schema + "." + t.Name

columns, err := h.autoColumns(t)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

var total int64
if err := db.Table(table).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

var rows []map[string]interface{}
if err := db.Table(table).Limit(modelsRowLimit + 1).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
truncated := false
if len(rows) > modelsRowLimit {
truncated = true
rows = rows[:modelsRowLimit]
}
for _, row := range rows {
for col, v := range row {
if v != nil && sensitiveColumnRe.MatchString(col) {
row[col] = "[redacted]"
}
}
}
if rows == nil {
rows = []map[string]interface{}{}
}

c.JSON(http.StatusOK, modelListing{
Key: t.key(),
Label: t.label(),
Group: string(modelGroupOther),
Table: table,
Columns: columns,
Count: total,
Truncated: truncated,
Rows: rows,
})
}
62 changes: 62 additions & 0 deletions controlplane/admin/models_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,66 @@ func TestModelsAPIPostgres(t *testing.T) {
if rec.Code != http.StatusNotFound {
t.Errorf("GET /models/nope = %d, want 404", rec.Code)
}

// ---- auto-discovery: the explorer is a database explorer, not a curated
// list. Tables with no typed descriptor (goose bookkeeping, migrations
// added later) must show up under "Other" with information_schema columns,
// and credential-shaped column VALUES must come back redacted.
if err := store.DB().Exec(`
CREATE TABLE explorer_probe (
id BIGSERIAL PRIMARY KEY,
note TEXT NOT NULL,
api_password TEXT NOT NULL
);
INSERT INTO explorer_probe (note, api_password) VALUES ('visible', 'super-secret-value');
`).Error; err != nil {
t.Fatalf("seed probe table: %v", err)
}

rec, body = get("/api/v1/models")
if rec.Code != http.StatusOK {
t.Fatalf("GET /models (post-probe) = %d", rec.Code)
}
if err := json.Unmarshal(body["models"], &summaries); err != nil {
t.Fatalf("decode summaries: %v", err)
}
probeKey := ""
gooseSeen := false
for _, s := range summaries {
if strings.HasSuffix(s.Key, ".explorer_probe") && s.Group == "Other" {
probeKey = s.Key
if s.Count != 1 {
t.Errorf("probe table count = %d, want 1", s.Count)
}
}
if strings.HasSuffix(s.Key, ".goose_db_version") {
gooseSeen = true
}
}
if probeKey == "" {
t.Fatalf("auto-discovery missed explorer_probe; sidebar: %v", summaries)
}
if !gooseSeen {
t.Fatalf("auto-discovery missed goose_db_version; sidebar: %v", summaries)
}

rec, _ = get("/api/v1/models/" + probeKey)
if rec.Code != http.StatusOK {
t.Fatalf("GET auto table = %d body %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "super-secret-value") {
t.Fatalf("auto table leaked a credential-shaped column value:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "[redacted]") || !strings.Contains(rec.Body.String(), "visible") {
t.Fatalf("auto table body missing redaction marker or plain column:\n%s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"api_password"`) {
t.Fatalf("auto table columns missing api_password header:\n%s", rec.Body.String())
}

// A crafted auto key that information_schema does not return → 404, never SQL.
rec, _ = get("/api/v1/models/auto:public.pg_shadow%3B--")
if rec.Code != http.StatusNotFound {
t.Errorf("crafted auto key = %d, want 404", rec.Code)
}
}
16 changes: 16 additions & 0 deletions controlplane/admin/reshard.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type ReshardStore interface {
CreateReshardOperation(op *configstore.ReshardOperation) error
GetReshardOperation(id int64) (*configstore.ReshardOperation, error)
ListReshardOperationsForOrg(orgID string, limit int) ([]configstore.ReshardOperation, error)
ListReshardOperations(limit int) ([]configstore.ReshardOperation, error)
ListReshardLog(opID, afterID int64, limit int) ([]configstore.ReshardLogEntry, error)
RequestReshardCancel(id int64) (bool, error)
FinishPendingReshardOperation(id int64, state configstore.ReshardState, errMsg string) (bool, error)
Expand Down Expand Up @@ -85,6 +86,7 @@ func RegisterReshardAPI(r *gin.RouterGroup, store ReshardStore, lister DucklingM
h := &reshardHandler{store: store, lister: lister, stash: stash, cluster: cluster}
r.POST("/orgs/:id/reshard", h.start)
r.GET("/orgs/:id/reshards", h.listForOrg)
r.GET("/reshards", h.listAll)
r.GET("/reshards/:opid", h.get)
r.GET("/reshards/:opid/log", h.log)
r.GET("/reshards/targets", h.targets)
Expand Down Expand Up @@ -327,6 +329,20 @@ func (h *reshardHandler) currentShard(c *gin.Context, ducklingName string) strin
return cnpgShardFromEndpoint(ms.Endpoint)
}

// listAll returns operations across every org, newest first — the console's
// global Reshards page.
func (h *reshardHandler) listAll(c *gin.Context) {
ops, err := h.store.ListReshardOperations(parseIntDefault(c.Query("limit"), 100))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if ops == nil {
ops = []configstore.ReshardOperation{}
}
c.JSON(http.StatusOK, gin.H{"operations": ops})
}

func (h *reshardHandler) listForOrg(c *gin.Context) {
ops, err := h.store.ListReshardOperationsForOrg(c.Param("id"), parseIntDefault(c.Query("limit"), 50))
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions controlplane/admin/reshard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ func (f *fakeReshardStore) ListReshardOperationsForOrg(orgID string, _ int) ([]c
return out, nil
}

func (f *fakeReshardStore) ListReshardOperations(_ int) ([]configstore.ReshardOperation, error) {
var out []configstore.ReshardOperation
for _, op := range f.ops {
out = append(out, *op)
}
return out, nil
}

func (f *fakeReshardStore) ListReshardLog(opID, afterID int64, _ int) ([]configstore.ReshardLogEntry, error) {
var out []configstore.ReshardLogEntry
for _, e := range f.logs {
Expand Down Expand Up @@ -275,6 +283,11 @@ func TestReshardGetListLogCancel(t *testing.T) {
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"operations"`) {
t.Fatalf("list status = %d body %s", w.Code, w.Body.String())
}
// Global list (nav page): same envelope, all orgs.
w = doJSON(r, http.MethodGet, "/api/v1/reshards", "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"operations"`) {
t.Fatalf("global list status = %d body %s", w.Code, w.Body.String())
}

// Incremental log poll.
_ = store.AppendReshardLog(1, "info", "alpha")
Expand Down
2 changes: 2 additions & 0 deletions controlplane/admin/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Overview } from "@/pages/Overview";
import { Orgs } from "@/pages/Orgs";
import { OrgDetail } from "@/pages/OrgDetail";
import { ReshardForm } from "@/pages/ReshardForm";
import { Reshards } from "@/pages/Reshards";
import { ReshardOperation } from "@/pages/ReshardOperation";
import { UsersPage } from "@/pages/Users";
import { Live } from "@/pages/Live";
Expand All @@ -33,6 +34,7 @@ export default function App() {
<Route path="/orgs" element={<Orgs />} />
<Route path="/orgs/:id" element={<OrgDetail />} />
<Route path="/orgs/:id/reshard" element={<ReshardForm />} />
<Route path="/reshards" element={<Reshards />} />
<Route path="/reshards/:opId" element={<ReshardOperation />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/live" element={<Live />} />
Expand Down
2 changes: 2 additions & 0 deletions controlplane/admin/ui/src/components/nav.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Activity,
AlertTriangle,
ArrowLeftRight,
Building2,
Database,
LayoutDashboard,
Expand Down Expand Up @@ -30,6 +31,7 @@ export const NAV: NavItem[] = [
{ to: "/nodes", label: "Nodes", icon: Network },
{ to: "/workers", label: "Workers", icon: Server },
{ to: "/metrics", label: "Metrics", icon: LineChart },
{ to: "/reshards", label: "Reshards", icon: ArrowLeftRight },
{ to: "/configstore", label: "Config Store", icon: Database },
{ to: "/impersonate", label: "Impersonate", icon: TerminalSquare, adminOnly: true },
{ to: "/audit", label: "Audit", icon: ScrollText, adminOnly: true },
Expand Down
Loading
Loading