Files
George Karr a25ae3ddfc Adding initial tool - dibble the tapir for seeding data (#46122)
## Overview

`dibble` is a one-stop CLI for seeding a Fleet server with test data —
users, teams, policies, reports, labels, scripts, MDM profiles,
software, secrets, CAs, and vulns — replacing ~8 ad-hoc seeding tools
with a single binary.

It makes it easy to:
- **Spin up a populated dev/test server in one command** — `dibble all`
plants everything with sensible, idempotent defaults.
- **Skip the flag-memorization** — running `dibble` with no args
launches an interactive wizard that prompts for Fleet URL, API token,
theme, and which entities to seed, and offers to save the config to
`~/.dibble.yaml`.
- **Seed individual entity types** — `dibble users`, `dibble teams`,
`dibble policies`, etc., when you only need one slice.
- **Get themed, recognizable test data** — pick a theme (hitchhikers,
tng, lotr, ghibli, parksrec, …) so seeded names are easy to eyeball in
the UI.

Hosts are intentionally out of scope — `cmd/osquery-perf` still owns
that. `dibble hosts` is a thin convenience wrapper that picks a fleet,
fetches its enroll secret, and prints/runs the osquery-perf invocation
for you.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Dibble: a CLI tool to seed realistic test data, including an
interactive wizard and subcommands for
teams/users/software/policies/scripts/reports/profiles/labels/activities/enroll-secrets/hosts/vulns,
plus theme-driven “cas” and “ping”.
* Theme system: multiple curated themes to generate consistent seeded
identities, policies, software, labels, and scripts.
* **Chores**
* Ignored the built dibble binary and added a Makefile build target to
compile the dibble tool.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-17 11:07:05 -05:00

310 lines
10 KiB
Go

package seed
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// IDPOptions configures the IDP seeder. DSN points at a Fleet MySQL instance;
// like vulns and activities, this seeder writes directly to MySQL because the
// IDP-linkage tables (mdm_idp_accounts, host_mdm_idp_accounts, scim_users,
// host_scim_user) are normally populated by the MDM enrollment and SCIM sync
// flows rather than any public API.
type IDPOptions struct {
DSN string
UserCount int // how many seeded users get an mdm_idp_accounts row
HostCount int // how many hosts get a host_mdm_idp_accounts assignment
}
// idpUser is the subset of the GET /users response we care about.
type idpUser struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// idpHost is the subset of the GET /hosts response we care about.
type idpHost struct {
ID uint `json:"id"`
UUID string `json:"uuid"`
Hostname string `json:"hostname"`
}
// IDP seeds IDP linkage for the most-recently-created Fleet users so they
// surface on host detail pages. Users and hosts are fetched via the Fleet
// API (users most-recent first, so dibble-created users come up before the
// bootstrap admin); the IDP tables are written directly via the supplied
// MySQL DSN.
//
// Two layers are written per user:
// - mdm_idp_accounts (UUID-keyed) + host_mdm_idp_accounts (host UUID →
// account UUID). Used by the MDM enrollment flow.
// - scim_users (numeric id) + host_scim_user (numeric host id → scim user
// id). This is what the host details "User" card reads: GetEndUsers in
// server/fleet/hosts.go prefers SCIM and only falls back to host_emails
// for the username field, never populating IdpFullName from the legacy
// mdm_idp_accounts table.
//
// Assignment is round-robin: host[i] gets seeded_user[i % len(users)] so the
// host count can exceed the user count and the extras still pick up real
// IDP records. Idempotent: existing rows matched by their unique key (email
// for mdm_idp_accounts, user_name for scim_users, host_uuid/host_id for the
// linkage tables) are reused rather than re-inserted; linkage inserts use
// INSERT IGNORE.
func IDP(ctx context.Context, c Client, log Logger, opt IDPOptions) Result {
res := Result{Entity: "idp"}
if opt.UserCount <= 0 {
opt.UserCount = 3
}
if opt.HostCount < 0 {
opt.HostCount = 0
}
users, err := fetchUsersForIDP(c, opt.UserCount)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("list users: %w", err))
return res
}
if len(users) == 0 {
res.Errors = append(res.Errors, errors.New("no users found — run `dibble users` first"))
return res
}
hosts, err := fetchHostsForIDP(c, opt.HostCount)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("list hosts: %w", err))
return res
}
dsn, err := mysqlDSN(opt.DSN, false)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err))
return res
}
db, err := sql.Open("mysql", dsn)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err))
return res
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err))
return res
}
// 1. Upsert mdm_idp_accounts + scim_users for each user. The two writes
// are paired: if either fails for a user, neither identity is retained
// for host assignment so the round-robin in step 2 stays aligned and
// host i's mdm_idp_account always points at the same identity as host
// i's scim_user.
seeded := make([]seededIDPUser, 0, len(users))
for _, u := range users {
accUUID, accCreated, err := upsertIDPAccount(ctx, db, u)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("upsert idp account for %s: %w", u.Email, err))
continue
}
scimID, scimCreated, err := upsertSCIMUser(ctx, db, u)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("upsert scim user for %s: %w", u.Email, err))
continue
}
seeded = append(seeded, seededIDPUser{accountUUID: accUUID, scimUserID: scimID})
if accCreated {
res.Created++
log.Printf("idp account %s <%s>", u.Name, u.Email)
} else {
res.Skipped++
}
if scimCreated {
res.Created++
log.Printf("scim user %s <%s>", u.Name, u.Email)
} else {
res.Skipped++
}
}
if len(seeded) == 0 {
res.Errors = append(res.Errors, errors.New("no idp or scim users created or found"))
return res
}
// 2. Assign hosts (round-robin) to both linkage tables using the paired
// identities from step 1.
if len(hosts) == 0 && opt.HostCount > 0 {
log.Printf("idp: no hosts found — run osquery-perf to enroll some first")
}
for i, h := range hosts {
pair := seeded[i%len(seeded)]
assigned, err := assignHostToIDPAccount(ctx, db, h.UUID, pair.accountUUID)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("assign host %s to idp account: %w", h.UUID, err))
} else if assigned {
res.Created++
log.Printf("idp host %s (%s) → account %s", h.Hostname, h.UUID, pair.accountUUID)
} else {
res.Skipped++
}
assigned, err = assignHostToSCIMUser(ctx, db, h.ID, pair.scimUserID)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("assign host %d to scim user: %w", h.ID, err))
continue
}
if assigned {
res.Created++
log.Printf("scim host %s (id=%d) → scim_user_id=%d", h.Hostname, h.ID, pair.scimUserID)
} else {
res.Skipped++
}
}
return res
}
// seededIDPUser holds the IDs both IDP tables produced for a single user, so
// host assignments to mdm_idp_accounts and scim_users stay aligned.
type seededIDPUser struct {
accountUUID string
scimUserID uint
}
// fetchUsersForIDP pulls up to `limit` users from the Fleet API, sorted
// most-recently-created first so dibble-seeded users surface ahead of the
// bootstrap admin.
func fetchUsersForIDP(c Client, limit int) ([]idpUser, error) {
var resp struct {
Users []idpUser `json:"users"`
}
path := fmt.Sprintf("/api/latest/fleet/users?order_key=created_at&order_direction=desc&per_page=%d", limit)
if err := c.Get(path, &resp); err != nil {
return nil, err
}
return resp.Users, nil
}
// fetchHostsForIDP pulls up to `limit` hosts from the Fleet API. Returns an
// empty slice (not an error) when limit == 0 so callers can disable host
// assignment without a separate code path.
func fetchHostsForIDP(c Client, limit int) ([]idpHost, error) {
if limit == 0 {
return nil, nil
}
var resp struct {
Hosts []idpHost `json:"hosts"`
}
path := fmt.Sprintf("/api/latest/fleet/hosts?per_page=%d", limit)
if err := c.Get(path, &resp); err != nil {
return nil, err
}
return resp.Hosts, nil
}
// upsertIDPAccount returns the account UUID for the given user's email. If a
// row already exists (matched by the unique email), its UUID is reused and
// created=false. Otherwise a new UUID is generated, inserted, and returned
// with created=true.
func upsertIDPAccount(ctx context.Context, db *sql.DB, u idpUser) (accountUUID string, created bool, err error) {
if err := db.QueryRowContext(ctx,
`SELECT uuid FROM mdm_idp_accounts WHERE email = ?`, u.Email,
).Scan(&accountUUID); err == nil {
return accountUUID, false, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return "", false, err
}
accountUUID = uuid.NewString()
if _, err := db.ExecContext(ctx,
`INSERT INTO mdm_idp_accounts (uuid, username, fullname, email) VALUES (?, ?, ?, ?)`,
accountUUID, u.Email, u.Name, u.Email,
); err != nil {
return "", false, err
}
return accountUUID, true, nil
}
// assignHostToIDPAccount inserts a host_mdm_idp_accounts row, returning
// assigned=true when the row was created and false when an existing
// assignment for this host was preserved (the table has a UNIQUE on
// host_uuid).
func assignHostToIDPAccount(ctx context.Context, db *sql.DB, hostUUID, accountUUID string) (assigned bool, err error) {
r, err := db.ExecContext(ctx,
`INSERT IGNORE INTO host_mdm_idp_accounts (host_uuid, account_uuid) VALUES (?, ?)`,
hostUUID, accountUUID,
)
if err != nil {
return false, err
}
n, err := r.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// upsertSCIMUser returns the scim_users.id for the given user, matched by
// user_name (UNIQUE). user_name is set to the user's email so the seeded row
// lines up with what the SCIM sync would produce. given_name / family_name
// come from splitting the user's full name on the first space — what
// ScimUser.DisplayName() concatenates back together for the "Full name (IdP)"
// field on the host details card.
func upsertSCIMUser(ctx context.Context, db *sql.DB, u idpUser) (id uint, created bool, err error) {
if err := db.QueryRowContext(ctx,
`SELECT id FROM scim_users WHERE user_name = ?`, u.Email,
).Scan(&id); err == nil {
return id, false, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return 0, false, err
}
given, family := splitFullName(u.Name)
r, err := db.ExecContext(ctx,
`INSERT INTO scim_users (user_name, given_name, family_name, active) VALUES (?, ?, ?, 1)`,
u.Email, given, family,
)
if err != nil {
return 0, false, err
}
lastID, err := r.LastInsertId()
if err != nil {
return 0, false, err
}
return uint(lastID), true, nil
}
// assignHostToSCIMUser inserts a host_scim_user row, returning assigned=true
// when the row was created. host_scim_user.host_id is the PRIMARY KEY, so an
// existing mapping for this host is preserved (INSERT IGNORE).
func assignHostToSCIMUser(ctx context.Context, db *sql.DB, hostID, scimUserID uint) (assigned bool, err error) {
r, err := db.ExecContext(ctx,
`INSERT IGNORE INTO host_scim_user (host_id, scim_user_id) VALUES (?, ?)`,
hostID, scimUserID,
)
if err != nil {
return false, err
}
n, err := r.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// splitFullName breaks a single-string name into SCIM's given/family parts.
// Single-word names go entirely into given_name so DisplayName() still
// renders them.
func splitFullName(full string) (given, family string) {
full = strings.TrimSpace(full)
if i := strings.IndexByte(full, ' '); i > 0 {
return full[:i], strings.TrimSpace(full[i+1:])
}
return full, ""
}