Skip to content

Commit c7dfa2d

Browse files
committed
fix: platform-scope pre-auth identity paths (RLS regression under app role)
2 parents f6b0a9d + 1344699 commit c7dfa2d

4 files changed

Lines changed: 128 additions & 82 deletions

File tree

internal/domain/identity/service/identity_profile.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/BEDOLAGA-DEV/RemnaCore/internal/domain/identity/aggregate"
1010
"github.com/BEDOLAGA-DEV/RemnaCore/pkg/authutil"
1111
"github.com/BEDOLAGA-DEV/RemnaCore/pkg/domainevent"
12+
"github.com/BEDOLAGA-DEV/RemnaCore/pkg/tenantctx"
1213
)
1314

1415
// VerifyEmail validates the token, marks the user's email as verified, removes
@@ -26,7 +27,10 @@ func (s *Service) VerifyEmail(ctx context.Context, token string) error {
2627
return ErrTokenExpired
2728
}
2829

29-
return s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
30+
// Pre-auth (public /auth/verify-email): no tenant context, and
31+
// identity.platform_users is FORCE RLS, so run under the platform sentinel or
32+
// the user row is invisible and verification always fails.
33+
return s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
3034
user, err := s.repo.GetUserByIDForUpdate(txCtx, verification.UserID)
3135
if err != nil {
3236
return fmt.Errorf("finding user: %w", err)
@@ -45,19 +49,38 @@ func (s *Service) VerifyEmail(ctx context.Context, token string) error {
4549
})
4650
}
4751

48-
// GetMe retrieves the authenticated user's profile by ID.
52+
// GetMe retrieves the authenticated user's profile by ID. Wrapped in RunInTx so
53+
// the caller's scope (WithPlatformScope for admins / WithTenantID for tenant
54+
// users, both set by ShopResolver) reaches the DB as the app.tenant_id GUC —
55+
// without it, identity.platform_users FORCE RLS hides even the caller's own row.
4956
func (s *Service) GetMe(ctx context.Context, userID string) (*aggregate.PlatformUser, error) {
50-
user, err := s.repo.GetUserByID(ctx, userID)
51-
if err != nil {
57+
var user *aggregate.PlatformUser
58+
if err := s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
59+
u, err := s.repo.GetUserByID(txCtx, userID)
60+
if err != nil {
61+
return err
62+
}
63+
user = u
64+
return nil
65+
}); err != nil {
5266
return nil, fmt.Errorf("finding user: %w", err)
5367
}
5468
return user, nil
5569
}
5670

57-
// GetByTelegramID retrieves a user by their linked Telegram ID.
71+
// GetByTelegramID retrieves a user by their linked Telegram ID. Runs under the
72+
// platform sentinel: it is used by pre-auth Telegram flows with no tenant
73+
// context, and platform_users FORCE RLS would otherwise hide the row.
5874
func (s *Service) GetByTelegramID(ctx context.Context, telegramID int64) (*aggregate.PlatformUser, error) {
59-
user, err := s.repo.GetUserByTelegramID(ctx, telegramID)
60-
if err != nil {
75+
var user *aggregate.PlatformUser
76+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
77+
u, err := s.repo.GetUserByTelegramID(txCtx, telegramID)
78+
if err != nil {
79+
return err
80+
}
81+
user = u
82+
return nil
83+
}); err != nil {
6184
return nil, fmt.Errorf("finding user by telegram id: %w", err)
6285
}
6386
return user, nil
@@ -140,8 +163,18 @@ func (s *Service) ListUsers(ctx context.Context, limit, offset int) ([]*aggregat
140163
// publishes a PasswordResetRequested event. If the email is not found, no error
141164
// is returned to prevent user enumeration.
142165
func (s *Service) RequestPasswordReset(ctx context.Context, email string) error {
143-
user, err := s.repo.GetUserByEmail(ctx, email)
144-
if err != nil {
166+
// Pre-auth (public /auth/forgot-password): platform sentinel so the email
167+
// lookup on FORCE-RLS platform_users can see the user.
168+
ctx = tenantctx.WithPlatformScope(ctx)
169+
var user *aggregate.PlatformUser
170+
if err := s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
171+
u, uerr := s.repo.GetUserByEmail(txCtx, email)
172+
if uerr != nil {
173+
return uerr
174+
}
175+
user = u
176+
return nil
177+
}); err != nil {
145178
if errors.Is(err, ErrNotFound) {
146179
// Silently succeed to prevent email enumeration.
147180
return nil
@@ -198,7 +231,9 @@ func (s *Service) ResetPassword(ctx context.Context, token, newPassword string)
198231
return fmt.Errorf("hashing password: %w", err)
199232
}
200233

201-
return s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
234+
// Pre-auth (public /auth/reset-password): platform sentinel so the FOR UPDATE
235+
// read on FORCE-RLS platform_users can see the user.
236+
return s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
202237
user, err := s.repo.GetUserByIDForUpdate(txCtx, reset.UserID)
203238
if err != nil {
204239
return fmt.Errorf("finding user: %w", err)

internal/domain/identity/service/identity_service.go

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,6 @@ type CreateFirstAdminInput struct {
191191
// Register creates a new user, generates an email verification token, and
192192
// publishes a UserRegistered event.
193193
func (s *Service) Register(ctx context.Context, input RegisterInput) (*RegisterResult, error) {
194-
// Check for duplicate email.
195-
existing, err := s.repo.GetUserByEmail(ctx, input.Email)
196-
if err != nil && !errors.Is(err, ErrNotFound) {
197-
return nil, fmt.Errorf("checking email: %w", err)
198-
}
199-
if existing != nil {
200-
return nil, ErrEmailTaken
201-
}
202-
203194
now := s.clock.Now()
204195
user, err := aggregate.NewPlatformUser(input.Email, input.Password, now)
205196
if err != nil {
@@ -211,7 +202,18 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*RegisterR
211202
return nil, fmt.Errorf("creating email verification: %w", err)
212203
}
213204

214-
if err := s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
205+
// Platform sentinel: the duplicate-email check and CreateUser both touch
206+
// identity.platform_users (FORCE RLS); pre-auth registration has no tenant
207+
// context, so without it the dup-check is blind (RLS-hidden) and the insert's
208+
// scope is undefined. The check runs inside the same tx to close the TOCTOU.
209+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
210+
existing, cerr := s.repo.GetUserByEmail(txCtx, input.Email)
211+
if cerr != nil && !errors.Is(cerr, ErrNotFound) {
212+
return fmt.Errorf("checking email: %w", cerr)
213+
}
214+
if existing != nil {
215+
return ErrEmailTaken
216+
}
215217
if err := s.repo.CreateUser(txCtx, user); err != nil {
216218
return fmt.Errorf("persisting user: %w", err)
217219
}
@@ -236,8 +238,19 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*RegisterR
236238
// ErrInvalidCredentials for both unknown emails and wrong passwords to avoid
237239
// leaking user existence.
238240
func (s *Service) Login(ctx context.Context, input LoginInput) (*LoginResult, error) {
239-
user, err := s.repo.GetUserByEmail(ctx, input.Email)
240-
if err != nil {
241+
// The email lookup reads identity.platform_users, which is FORCE RLS. Login
242+
// is pre-auth (no tenant known yet) and must see users across every tenant,
243+
// so it runs under the platform sentinel; otherwise the RLS policy hides
244+
// every row and login always fails as "invalid credentials".
245+
var user *aggregate.PlatformUser
246+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
247+
u, uerr := s.repo.GetUserByEmail(txCtx, input.Email)
248+
if uerr != nil {
249+
return uerr
250+
}
251+
user = u
252+
return nil
253+
}); err != nil {
241254
if errors.Is(err, ErrNotFound) {
242255
return nil, ErrInvalidCredentials
243256
}
@@ -286,8 +299,19 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*Login
286299
return nil, ErrSessionExpired
287300
}
288301

289-
user, err := s.repo.GetUserByID(ctx, session.UserID)
290-
if err != nil {
302+
// GetUserByID reads identity.platform_users (FORCE RLS). Refresh is pre-auth
303+
// (the JWT is expired/absent, no tenant scope), so it runs under the platform
304+
// sentinel — otherwise RLS hides the user row and every refresh fails,
305+
// logging the operator out when the access token expires.
306+
var user *aggregate.PlatformUser
307+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
308+
u, uerr := s.repo.GetUserByID(txCtx, session.UserID)
309+
if uerr != nil {
310+
return uerr
311+
}
312+
user = u
313+
return nil
314+
}); err != nil {
291315
return nil, fmt.Errorf("finding user: %w", err)
292316
}
293317

@@ -339,8 +363,17 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*Login
339363
// SetupNeeded reports whether the platform has no administrator yet, i.e. the
340364
// first-run admin setup wizard should be shown.
341365
func (s *Service) SetupNeeded(ctx context.Context) (bool, error) {
342-
n, err := s.repo.CountAdmins(ctx)
343-
if err != nil {
366+
// CountAdmins reads identity.platform_users (FORCE RLS). This is a public,
367+
// pre-auth endpoint with no tenant context, so it must run under the platform
368+
// sentinel — otherwise RLS hides the admin rows and the count is always 0,
369+
// which wrongly reports "setup needed" forever (and traps operators on the
370+
// /setup wizard after logout instead of showing /login).
371+
var n int64
372+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
373+
var cerr error
374+
n, cerr = s.repo.CountAdmins(txCtx)
375+
return cerr
376+
}); err != nil {
344377
return false, fmt.Errorf("counting admins: %w", err)
345378
}
346379
return n == 0, nil
@@ -358,7 +391,10 @@ func (s *Service) CreateFirstAdmin(ctx context.Context, in CreateFirstAdminInput
358391
}
359392

360393
var result *LoginResult
361-
if err := s.txRunner.RunInTx(ctx, func(txCtx context.Context) error {
394+
// Runs under the platform sentinel: CountAdmins / CreateUser touch
395+
// identity.platform_users (FORCE RLS), and without it the count would always
396+
// see 0 admins (RLS-hidden) and create a duplicate admin on every call.
397+
if err := s.txRunner.RunInTx(tenantctx.WithPlatformScope(ctx), func(txCtx context.Context) error {
362398
if err := s.repo.AcquireBootstrapLock(txCtx); err != nil {
363399
return err
364400
}

internal/domain/identity/service_test.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ func TestService_Register_Success(t *testing.T) {
4949
svc, repo, pub := newTestService(t)
5050
ctx := context.Background()
5151

52-
repo.On("GetUserByEmail", ctx, "alice@example.com").Return(nil, identity.ErrNotFound)
53-
repo.On("CreateUser", ctx, mock.AnythingOfType("*aggregate.PlatformUser")).Return(nil)
54-
repo.On("CreateEmailVerification", ctx, mock.AnythingOfType("*aggregate.EmailVerification")).Return(nil)
55-
pub.On("Publish", ctx, mock.AnythingOfType("domainevent.Event")).Return(nil)
52+
repo.On("GetUserByEmail", mock.Anything, "alice@example.com").Return(nil, identity.ErrNotFound)
53+
repo.On("CreateUser", mock.Anything, mock.AnythingOfType("*aggregate.PlatformUser")).Return(nil)
54+
repo.On("CreateEmailVerification", mock.Anything, mock.AnythingOfType("*aggregate.EmailVerification")).Return(nil)
55+
pub.On("Publish", mock.Anything, mock.AnythingOfType("domainevent.Event")).Return(nil)
5656

5757
result, err := svc.Register(ctx, identity.RegisterInput{
5858
Email: "alice@example.com",
@@ -72,7 +72,7 @@ func TestService_Register_DuplicateEmail(t *testing.T) {
7272
ctx := context.Background()
7373

7474
existing := &identity.PlatformUser{ID: "existing-id", Email: "alice@example.com"}
75-
repo.On("GetUserByEmail", ctx, "alice@example.com").Return(existing, nil)
75+
repo.On("GetUserByEmail", mock.Anything, "alice@example.com").Return(existing, nil)
7676

7777
_, err := svc.Register(ctx, identity.RegisterInput{
7878
Email: "alice@example.com",
@@ -96,9 +96,9 @@ func TestService_Login_Success(t *testing.T) {
9696
Role: identity.RoleCustomer,
9797
}
9898

99-
repo.On("GetUserByEmail", ctx, "alice@example.com").Return(user, nil)
100-
repo.On("CreateSession", ctx, mock.AnythingOfType("*aggregate.Session")).Return(nil)
101-
pub.On("Publish", ctx, mock.AnythingOfType("domainevent.Event")).Return(nil)
99+
repo.On("GetUserByEmail", mock.Anything, "alice@example.com").Return(user, nil)
100+
repo.On("CreateSession", mock.Anything, mock.AnythingOfType("*aggregate.Session")).Return(nil)
101+
pub.On("Publish", mock.Anything, mock.AnythingOfType("domainevent.Event")).Return(nil)
102102

103103
result, err := svc.Login(ctx, identity.LoginInput{
104104
Email: "alice@example.com",
@@ -126,7 +126,7 @@ func TestService_Login_WrongPassword(t *testing.T) {
126126
Role: identity.RoleCustomer,
127127
}
128128

129-
repo.On("GetUserByEmail", ctx, "alice@example.com").Return(user, nil)
129+
repo.On("GetUserByEmail", mock.Anything, "alice@example.com").Return(user, nil)
130130

131131
_, err := svc.Login(ctx, identity.LoginInput{
132132
Email: "alice@example.com",
@@ -140,7 +140,7 @@ func TestService_Login_UserNotFound(t *testing.T) {
140140
svc, repo, _ := newTestService(t)
141141
ctx := context.Background()
142142

143-
repo.On("GetUserByEmail", ctx, "unknown@example.com").Return(nil, identity.ErrNotFound)
143+
repo.On("GetUserByEmail", mock.Anything, "unknown@example.com").Return(nil, identity.ErrNotFound)
144144

145145
_, err := svc.Login(ctx, identity.LoginInput{
146146
Email: "unknown@example.com",
@@ -155,12 +155,12 @@ func TestService_SetupNeeded(t *testing.T) {
155155
svc, repo, _ := newTestService(t)
156156
ctx := context.Background()
157157

158-
repo.On("CountAdmins", ctx).Return(int64(0), nil).Once()
158+
repo.On("CountAdmins", mock.Anything).Return(int64(0), nil).Once()
159159
need, err := svc.SetupNeeded(ctx)
160160
require.NoError(t, err)
161161
assert.True(t, need)
162162

163-
repo.On("CountAdmins", ctx).Return(int64(1), nil).Once()
163+
repo.On("CountAdmins", mock.Anything).Return(int64(1), nil).Once()
164164
need, err = svc.SetupNeeded(ctx)
165165
require.NoError(t, err)
166166
assert.False(t, need)
@@ -238,11 +238,11 @@ func TestService_VerifyEmail_Success(t *testing.T) {
238238
EmailVerified: false,
239239
}
240240

241-
repo.On("GetEmailVerification", ctx, "abc123").Return(verification, nil)
242-
repo.On("GetUserByIDForUpdate", ctx, "user-1").Return(user, nil)
243-
repo.On("UpdateUser", ctx, mock.AnythingOfType("*aggregate.PlatformUser")).Return(nil)
244-
repo.On("DeleteEmailVerification", ctx, "v-1").Return(nil)
245-
pub.On("Publish", ctx, mock.AnythingOfType("domainevent.Event")).Return(nil)
241+
repo.On("GetEmailVerification", mock.Anything, "abc123").Return(verification, nil)
242+
repo.On("GetUserByIDForUpdate", mock.Anything, "user-1").Return(user, nil)
243+
repo.On("UpdateUser", mock.Anything, mock.AnythingOfType("*aggregate.PlatformUser")).Return(nil)
244+
repo.On("DeleteEmailVerification", mock.Anything, "v-1").Return(nil)
245+
pub.On("Publish", mock.Anything, mock.AnythingOfType("domainevent.Event")).Return(nil)
246246

247247
err := svc.VerifyEmail(ctx, "abc123")
248248

@@ -263,7 +263,7 @@ func TestService_VerifyEmail_Expired(t *testing.T) {
263263
ExpiresAt: time.Now().Add(-time.Hour), // expired
264264
}
265265

266-
repo.On("GetEmailVerification", ctx, "abc123").Return(verification, nil)
266+
repo.On("GetEmailVerification", mock.Anything, "abc123").Return(verification, nil)
267267

268268
err := svc.VerifyEmail(ctx, "abc123")
269269

@@ -352,7 +352,7 @@ func TestService_RefreshToken_Expired(t *testing.T) {
352352
ExpiresAt: time.Now().Add(-time.Hour), // expired
353353
}
354354

355-
repo.On("GetSessionByRefreshToken", ctx, "old-refresh-token").Return(session, nil)
355+
repo.On("GetSessionByRefreshToken", mock.Anything, "old-refresh-token").Return(session, nil)
356356

357357
_, err := svc.RefreshToken(ctx, "old-refresh-token")
358358

scripts/first-deploy.sh

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -167,31 +167,9 @@ until curl -sf http://localhost:80/healthz > /dev/null 2>&1; do
167167
done
168168
ok "Health check passed"
169169

170-
# ── Step 8: Create admin account ───────────────────────────────────────────
171-
ADMIN_EMAIL="admin@remnacore.local"
172-
ADMIN_CHECK=$(docker compose exec -T platform-db psql -U platform -d remnacore -tAc \
173-
"SELECT 1 FROM identity.platform_users WHERE email = '${ADMIN_EMAIL}'" 2>/dev/null || true)
174-
175-
if [ "$ADMIN_CHECK" = "1" ]; then
176-
warn "Admin account already exists (${ADMIN_EMAIL}), skipping"
177-
ADMIN_PASSWORD="<already created>"
178-
else
179-
ADMIN_PASSWORD="$(rand_secret 24)"
180-
# Register via API
181-
REGISTER_RESULT=$(curl -sf -X POST http://localhost:80/api/auth/register \
182-
-H "Content-Type: application/json" \
183-
-d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" 2>/dev/null || true)
184-
185-
if [ -z "$REGISTER_RESULT" ]; then
186-
fail "Failed to register admin account"
187-
fi
188-
189-
# Promote to admin role
190-
docker compose exec -T platform-db psql -U platform -d remnacore \
191-
-c "UPDATE identity.platform_users SET role = 'admin', email_verified = true WHERE email = '${ADMIN_EMAIL}';" > /dev/null 2>&1
192-
193-
ok "Admin account created"
194-
fi
170+
# The first administrator is NOT created here — open the Admin panel and the
171+
# first-run setup wizard will prompt you to create it (POST /api/setup). The
172+
# wizard is reachable only while no admin exists and bounces to /login after.
195173

196174
# ── Done ───────────────────────────────────────────────────────────────────
197175
SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost")
@@ -205,16 +183,13 @@ echo " Remnawave panel: http://${SERVER_IP}:8080"
205183
echo " API: http://${SERVER_IP}:80/api"
206184
echo " Metrics: http://${SERVER_IP}:80/metrics"
207185
echo ""
208-
if [ "$ADMIN_PASSWORD" != "<already created>" ]; then
209-
echo -e "${GREEN} Admin credentials:${NC}"
210-
echo " Email: ${ADMIN_EMAIL}"
211-
echo " Password: ${ADMIN_PASSWORD}"
212-
echo ""
213-
echo " ⚠ Save these credentials — they will not be shown again."
214-
fi
186+
echo -e "${GREEN} First-run setup:${NC}"
187+
echo " Open the Admin panel and create your administrator account"
188+
echo " (the setup wizard appears automatically on first launch)."
215189
echo ""
216190
echo -e "${YELLOW}ACTION REQUIRED:${NC}"
217-
echo " 1. Open Remnawave panel at http://${SERVER_IP}:8080"
218-
echo " 2. Create an API token in the panel"
219-
echo " 3. Edit .env and set REMNAWAVE_API_TOKEN=<your-token>"
220-
echo " 4. Run: docker compose restart remnacore"
191+
echo " 1. Open the Admin panel at http://${SERVER_IP}:8081 and create the admin"
192+
echo " 2. Open Remnawave panel at http://${SERVER_IP}:8080"
193+
echo " 3. Create an API token in the Remnawave panel"
194+
echo " 4. Edit .env and set REMNAWAVE_API_TOKEN=<your-token>"
195+
echo " 5. Run: docker compose restart remnacore"

0 commit comments

Comments
 (0)