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:
@@ -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, `
|
||||
|
||||
@@ -577,3 +577,33 @@ func TestDashboardPermissionsAdapterRejectsAdminRole(t *testing.T) {
|
||||
t.Fatal("expected an error granting role=admin via a dashboard permission")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUserByEmail is the regression test for
|
||||
// cmd/enterprise-auth/main.go's -grant-membership-user-email flag,
|
||||
// which looks up an existing user by email rather than requiring the
|
||||
// operator to already know their generated UUID.
|
||||
func TestGetUserByEmail(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
email := "lookup-" + uniqueSuffix() + "@example.com"
|
||||
|
||||
created, err := s.UpsertUserBySSO(ctx, "sub-"+uniqueSuffix(), email, "Lookup Me")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if got.ID != created.ID {
|
||||
t.Fatalf("GetUserByEmail returned ID %q, want %q", got.ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.GetUserByEmail(context.Background(), "does-not-exist-"+uniqueSuffix()+"@example.com"); err != ErrNotFound {
|
||||
t.Fatalf("GetUserByEmail error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user