From 149ec2adaef7385dd6b831318738da078bbdece0 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 5 Jun 2026 23:36:44 +0200 Subject: [PATCH] feat(store): ListAllUsers for scheduler user enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-process scheduler (ADR-018) needs to enumerate every user to run a discovery pass each. user_identities is the un-RLS'd map; add ListAllUsers as a plain pool query (no withUser) — the same enumerate-then-act pattern UserBySubject and the login_events gate query established. Scoping it to a single user would defeat the point; user_identities carries no RLS by design. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/store/identity.go | 35 ++++++++++++++++++++++++ internal/adapters/store/identity_test.go | 33 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/internal/adapters/store/identity.go b/internal/adapters/store/identity.go index a33fa64..da6041d 100644 --- a/internal/adapters/store/identity.go +++ b/internal/adapters/store/identity.go @@ -31,6 +31,41 @@ func (s *Store) UserBySubject(ctx context.Context, subject string) (userID strin return userID, true, nil } +// UserIdentity is one (userID, dexSubject) pair from the un-RLS'd +// user_identities map — the unit the scheduler enumerates to run a discovery +// pass per user (ADR-018). +type UserIdentity struct { + UserID string + DexSubject string +} + +// ListAllUsers returns every (userID, dexSubject) pair from user_identities. It +// runs as a plain pool query WITHOUT withUser — intentional and legitimate: +// user_identities is un-RLS'd auth plumbing (like UserBySubject), and the +// scheduler enumerating all users to run their discovery passes is an admin +// operation that cannot be scoped to any single user. Order is unspecified. +func (s *Store) ListAllUsers(ctx context.Context) ([]UserIdentity, error) { + rows, err := s.pool.Query(ctx, + `SELECT user_id, dex_subject FROM user_identities`) + if err != nil { + return nil, fmt.Errorf("store: list all users: %w", err) + } + defer rows.Close() + + var users []UserIdentity + for rows.Next() { + var u UserIdentity + if err := rows.Scan(&u.UserID, &u.DexSubject); err != nil { + return nil, fmt.Errorf("store: scan user identity: %w", err) + } + users = append(users, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate user identities: %w", err) + } + return users, nil +} + // RegisterUser creates the tapir user for a Dex subject and the identity mapping // that points to it, returning the new user_id. It errors with // ErrSubjectRegistered if the subject already maps. diff --git a/internal/adapters/store/identity_test.go b/internal/adapters/store/identity_test.go index 1b9f6e0..87e6ac6 100644 --- a/internal/adapters/store/identity_test.go +++ b/internal/adapters/store/identity_test.go @@ -85,6 +85,39 @@ func TestRegisterUserRejectsDuplicateSubject(t *testing.T) { require.Equal(t, first, got) } +func TestListAllUsersReturnsEveryIdentity(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + empty, err := s.ListAllUsers(ctx) + require.NoError(t, err) + require.Empty(t, empty, "no registrations yet → empty slice") + + const subjectC = "dex|carol-789" + idA, err := s.RegisterUser(ctx, subjectA, "Alice") + require.NoError(t, err) + idB, err := s.RegisterUser(ctx, subjectB, "Bob") + require.NoError(t, err) + idC, err := s.RegisterUser(ctx, subjectC, "Carol") + require.NoError(t, err) + + users, err := s.ListAllUsers(ctx) + require.NoError(t, err) + require.Len(t, users, 3) + + // Order is unspecified; compare as a set of (userID, subject) pairs. + got := make(map[string]string, len(users)) + for _, u := range users { + got[u.DexSubject] = u.UserID + } + require.Equal(t, map[string]string{ + subjectA: idA, + subjectB: idB, + subjectC: idC, + }, got) +} + func TestRegisterUserDistinctSubjectsGetDistinctUsers(t *testing.T) { ctx := context.Background() s := newStore(t)