Add enterprise-auth -create-tenant/-grant-membership-* operator flags

Replaces the manual psql INSERT dance phase-4-runbook.md's §3a/§3b
documented for bootstrapping the very first tenant_memberships row
(create the tenant, log in once so UpsertUserBySSO creates a users row,
hand-write an INSERT with that user's UUID). Two new offline operator
flags, same "gated by access to enterprise-auth's own environment, not
a network-reachable endpoint" shape as -mint-service-token and
enterprise-api's -provision-tenant:

- -create-tenant=<id> [-display-name=<name>]: creates a tenant row in
  rbacstore (control-plane only -- pair with enterprise-api
  -provision-tenant separately for ClickHouse/Tantivy data-plane
  provisioning, still two operator actions today, a named gap this
  doesn't unify). Refuses to run twice for the same id.
- -grant-membership-tenant/-grant-membership-user-email/
  -grant-membership-role: grants a tenant_memberships row by email
  instead of requiring the operator to hand-look-up a UUID. The user
  must already exist (attempted an SSO login at least once -- this flag
  deliberately never creates a user itself, since that identity has to
  come from a real IdP round trip). role=owner also calls SetOwner,
  since Owner is a dedicated tenants.owner_user_id column, not just the
  highest tenant_memberships role.

Deliberately kept as offline flags rather than an authenticated HTTP
admin API: an HTTP endpoint would have to solve "who's allowed to
create the very first tenant/membership" itself, a real bootstrap
problem the offline-flag pattern already used elsewhere in this binary
sidesteps entirely.

New rbacstore.GetUserByEmail supports the email-based lookup (email is
already the natural key UpsertUserBySSO upserts on). Covered by two new
skip-gated integration tests in rbacstore_test.go, same
RBACSTORE_TEST_POSTGRES_ADDR convention as the rest of this package --
not run against a live database in this environment, consistent with
everything else in this phase's Postgres-backed work. No dedicated test
for the main.go flag handlers themselves, matching the existing
precedent for -mint-service-token/-provision-tenant (neither has one
either).

Docs updated: phase-4-runbook.md's §3a/§3b bootstrap steps and its
"known gaps" list, enterprise/README.md gets a new "Bootstrapping a
tenant and its first human user" section and a stale "there's no login
flow to issue a human session yet" line (obsolete since OIDC/SAML login
shipped) is fixed.
This commit is contained in:
2026-08-14 07:57:33 -07:00
parent 243f4dc2ab
commit b8b6a8fd7b
5 changed files with 224 additions and 30 deletions
@@ -111,6 +111,25 @@ func (s *Store) UpsertUserBySSO(ctx context.Context, ssoSubject, email, displayN
return &u, nil
}
// GetUserByEmail supports enterprise-auth's -grant-membership-* operator
// flags (cmd/enterprise-auth/main.go): granting a tenant_memberships row
// by email is friendlier than requiring the operator to already know a
// user's generated UUID, and email is the same natural key
// UpsertUserBySSO already upserts on.
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var u User
row := s.pool.QueryRow(ctx, `
SELECT id, email, display_name, sso_subject, created_at, updated_at
FROM users WHERE email = $1`, email)
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.SSOSubject, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("rbacstore: getting user by email: %w", err)
}
return &u, nil
}
func (s *Store) GetUser(ctx context.Context, id string) (*User, error) {
var u User
row := s.pool.QueryRow(ctx, `