Skip to content

Commit 44431da

Browse files
committed
feat(rls): move ackify_app role creation from init script to migrate tool
BREAKING CHANGE: ACKIFY_APP_PASSWORD environment variable is now required for RLS support. The migrate tool creates the ackify_app role before running migrations, ensuring compatibility with existing deployments. Changes: - Add ensureAppRole() in cmd/migrate to create/update ackify_app role - Remove docker/init-scripts/01-create-app-user.sh (no longer needed) - Update compose.yml: add ACKIFY_APP_PASSWORD, backend connects as ackify_app - Update migration 0016: remove conditional role creation - Add RLS documentation (docs/en/configuration/rls.md, docs/fr/configuration/rls.md) - Update configuration docs with RLS section and security checklist Migration path for existing deployments: 1. Set ACKIFY_APP_PASSWORD in .env 2. Run docker compose up (migrate will create the role automatically)
1 parent eca55c6 commit 44431da

36 files changed

+2284
-370
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ install/
5353
client_secret*.json
5454

5555
# Node.js (if any frontend assets)
56+
**/node_modules/
5657
node_modules/
5758
npm-debug.log
5859
yarn-error.log

.env.example

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ ACKIFY_LOG_LEVEL=info
55
ACKIFY_LOG_FORMAT=classic
66

77
# Database Configuration
8-
POSTGRES_USER=ackifyr
98
POSTGRES_PASSWORD=your_secure_password
10-
POSTGRES_DB=ackify
11-
ACKIFY_DB_DSN=postgres://ackifyr:your_secure_password@localhost:5432/ackify?sslmode=disable
9+
ACKIFY_APP_PASSWORD=ackify_app_password
1210

1311
# ============================================================================
1412
# Authentication Configuration

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.2.8] - 2025-12-15
9+
10+
### 🔐 Multi-Tenant Security & Row Level Security
11+
12+
Version majeure de sécurité introduisant l'isolation des données par tenant avec PostgreSQL Row Level Security (RLS).
13+
14+
### Added
15+
16+
- **Row Level Security (RLS)**
17+
- Isolation des données au niveau PostgreSQL avec politiques RLS
18+
- Protection de 11 tables : documents, signatures, expected_signers, webhooks, reminder_logs, email_queue, checksum_verifications, webhook_deliveries, oauth_sessions, magic_link_tokens, magic_link_auth_attempts
19+
- Fonction `current_tenant_id()` pour récupérer le tenant de la session
20+
- `FORCE ROW LEVEL SECURITY` pour appliquer les politiques même aux propriétaires des tables
21+
- Comportement sécurisé par défaut : aucune donnée accessible si tenant non défini
22+
23+
- **Support Multi-Tenant**
24+
- Nouvelle table `instance_metadata` stockant l'UUID unique du tenant
25+
- Colonne `tenant_id` (UUID) ajoutée à toutes les tables métier et d'authentification
26+
- Index optimisés sur `tenant_id` pour des performances optimales
27+
- Triggers d'immutabilité empêchant la modification du `tenant_id` après création
28+
- Backfill automatique des données existantes avec le tenant de l'instance
29+
30+
- **Gestion du Rôle Applicatif**
31+
- Création automatique du rôle `ackify_app` par l'outil de migration
32+
- Séparation des privilèges (rôle applicatif vs rôle superuser)
33+
- Variable d'environnement `ACKIFY_APP_PASSWORD` pour définir le mot de passe du rôle
34+
- Privilèges par défaut configurés pour les futures tables
35+
36+
### Technical Details
37+
38+
**Nouvelles migrations :**
39+
- `0015_add_tenant_support.{up,down}.sql` - Support multi-tenant
40+
- `0016_add_rls_policies.{up,down}.sql` - Politiques RLS
41+
42+
**Fichiers modifiés :**
43+
- `backend/cmd/migrate/main.go` - Création du rôle `ackify_app`
44+
45+
**Sécurité :**
46+
- Les politiques RLS utilisent `USING` et `WITH CHECK` pour filtrer lectures et écritures
47+
- Les tokens magic link acceptent `tenant_id IS NULL` pour les requêtes de login
48+
- Les sessions OAuth sont isolées par tenant après authentification
49+
850
## [1.2.6] - 2025-12-08
951

1052
### 🏗️ Architecture & CI/CD
@@ -569,6 +611,7 @@ For users upgrading from v1.1.x to v1.2.0:
569611
- NULL UserName handling in database operations
570612
- Proper string conversion for UserName field
571613

614+
[1.2.8]: https://github.com/btouchard/ackify-ce/compare/v1.2.6...v1.2.8
572615
[1.2.6]: https://github.com/btouchard/ackify-ce/compare/v1.2.5...v1.2.6
573616
[1.2.5]: https://github.com/btouchard/ackify-ce/compare/v1.2.4...v1.2.5
574617
[1.2.4]: https://github.com/btouchard/ackify-ce/compare/v1.2.3...v1.2.4

backend/cmd/migrate/main.go

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package main
22

33
import (
4+
"database/sql"
45
"errors"
56
"flag"
67
"fmt"
78
"log"
89
"os"
9-
10-
"database/sql"
10+
"strings"
1111

1212
"github.com/golang-migrate/migrate/v4"
1313
"github.com/golang-migrate/migrate/v4/database/postgres"
@@ -52,6 +52,11 @@ func main() {
5252

5353
switch command {
5454
case "up":
55+
// Ensure ackify_app role exists before running migrations (for RLS support)
56+
if err := ensureAppRole(db); err != nil {
57+
log.Fatal("Failed to ensure ackify_app role:", err)
58+
}
59+
5560
err = m.Up()
5661
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
5762
log.Fatal("Migration up failed:", err)
@@ -128,10 +133,86 @@ func printUsage() {
128133
fmt.Println(" -db-dsn string Database DSN (or DB_DSN env var)")
129134
fmt.Println(" -migrations-path string Path to migrations (default: file://migrations)")
130135
fmt.Println()
136+
fmt.Println("Environment:")
137+
fmt.Println(" ACKIFY_APP_PASSWORD Password for the ackify_app role (required for RLS)")
138+
fmt.Println()
131139
fmt.Println("Examples:")
132140
fmt.Println(" migrate up")
133141
fmt.Println(" migrate down 2")
134142
fmt.Println(" migrate goto 5")
135143
fmt.Println(" migrate force 1 # For existing DB with only signatures table")
136144
fmt.Println(" migrate version")
137145
}
146+
147+
// ensureAppRole creates or updates the ackify_app role used for RLS.
148+
// The password is read from ACKIFY_APP_PASSWORD environment variable.
149+
// If not set, the function logs a warning and continues (for backward compatibility).
150+
// If set, the role is created (or password updated) before migrations run.
151+
func ensureAppRole(db *sql.DB) error {
152+
password := strings.TrimSpace(os.Getenv("ACKIFY_APP_PASSWORD"))
153+
if password == "" {
154+
log.Println("WARNING: ACKIFY_APP_PASSWORD not set. ackify_app role will not be created.")
155+
log.Println(" RLS migrations will fail if the role doesn't exist.")
156+
log.Println(" Set ACKIFY_APP_PASSWORD to enable RLS support.")
157+
return nil
158+
}
159+
160+
// Check if role exists
161+
var exists bool
162+
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'ackify_app')").Scan(&exists)
163+
if err != nil {
164+
return fmt.Errorf("failed to check if ackify_app role exists: %w", err)
165+
}
166+
167+
if exists {
168+
// Update password to ensure it matches environment
169+
_, err = db.Exec(fmt.Sprintf("ALTER ROLE ackify_app WITH PASSWORD '%s'", escapePassword(password)))
170+
if err != nil {
171+
return fmt.Errorf("failed to update ackify_app password: %w", err)
172+
}
173+
log.Println("ackify_app role exists, password updated")
174+
} else {
175+
// Create the role with all necessary attributes
176+
createSQL := fmt.Sprintf(`
177+
CREATE ROLE ackify_app WITH
178+
LOGIN
179+
PASSWORD '%s'
180+
NOCREATEDB
181+
NOCREATEROLE
182+
NOINHERIT
183+
NOREPLICATION
184+
CONNECTION LIMIT -1
185+
`, escapePassword(password))
186+
187+
_, err = db.Exec(createSQL)
188+
if err != nil {
189+
return fmt.Errorf("failed to create ackify_app role: %w", err)
190+
}
191+
log.Println("ackify_app role created successfully")
192+
}
193+
194+
// Grant CONNECT on database (idempotent)
195+
var dbName string
196+
err = db.QueryRow("SELECT current_database()").Scan(&dbName)
197+
if err != nil {
198+
return fmt.Errorf("failed to get current database name: %w", err)
199+
}
200+
201+
_, err = db.Exec(fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO ackify_app", dbName))
202+
if err != nil {
203+
return fmt.Errorf("failed to grant CONNECT to ackify_app: %w", err)
204+
}
205+
206+
// Grant USAGE on public schema (idempotent)
207+
_, err = db.Exec("GRANT USAGE ON SCHEMA public TO ackify_app")
208+
if err != nil {
209+
return fmt.Errorf("failed to grant USAGE on public schema: %w", err)
210+
}
211+
212+
return nil
213+
}
214+
215+
// escapePassword escapes single quotes in password for SQL
216+
func escapePassword(password string) string {
217+
return strings.ReplaceAll(password, "'", "''")
218+
}

backend/internal/infrastructure/auth/session_worker.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package auth
33

44
import (
55
"context"
6+
"database/sql"
67
"fmt"
78
"sync"
89
"time"
910

11+
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant"
1012
"github.com/btouchard/ackify-ce/backend/pkg/logger"
1113
)
1214

@@ -16,6 +18,10 @@ type SessionWorker struct {
1618
cleanupInterval time.Duration
1719
cleanupAge time.Duration
1820

21+
// RLS support
22+
db *sql.DB
23+
tenants tenant.Provider
24+
1925
ctx context.Context
2026
cancel context.CancelFunc
2127
wg sync.WaitGroup
@@ -39,7 +45,7 @@ func DefaultSessionWorkerConfig() SessionWorkerConfig {
3945
}
4046

4147
// NewSessionWorker creates a new OAuth session cleanup worker
42-
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig) *SessionWorker {
48+
func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig, db *sql.DB, tenants tenant.Provider) *SessionWorker {
4349
// Apply defaults
4450
if config.CleanupInterval <= 0 {
4551
config.CleanupInterval = 24 * time.Hour
@@ -54,6 +60,8 @@ func NewSessionWorker(sessionRepo SessionRepository, config SessionWorkerConfig)
5460
sessionRepo: sessionRepo,
5561
cleanupInterval: config.CleanupInterval,
5662
cleanupAge: config.CleanupAge,
63+
db: db,
64+
tenants: tenants,
5765
ctx: ctx,
5866
cancel: cancel,
5967
stopChan: make(chan struct{}),
@@ -148,7 +156,28 @@ func (w *SessionWorker) performCleanup() {
148156
logger.Logger.Debug("Starting OAuth session cleanup",
149157
"older_than", w.cleanupAge)
150158

151-
deleted, err := w.sessionRepo.DeleteExpired(ctx, w.cleanupAge)
159+
var deleted int64
160+
var err error
161+
162+
// Use RLS context if db and tenants are available
163+
if w.db != nil && w.tenants != nil {
164+
tenantID, tenantErr := w.tenants.CurrentTenant(ctx)
165+
if tenantErr != nil {
166+
logger.Logger.Error("Failed to get tenant for session cleanup",
167+
"error", tenantErr.Error())
168+
return
169+
}
170+
171+
err = tenant.WithTenantContext(ctx, w.db, tenantID, func(txCtx context.Context) error {
172+
var cleanupErr error
173+
deleted, cleanupErr = w.sessionRepo.DeleteExpired(txCtx, w.cleanupAge)
174+
return cleanupErr
175+
})
176+
} else {
177+
// No RLS - direct repository access (for tests)
178+
deleted, err = w.sessionRepo.DeleteExpired(ctx, w.cleanupAge)
179+
}
180+
152181
if err != nil {
153182
logger.Logger.Error("Failed to cleanup expired OAuth sessions",
154183
"error", err.Error())

backend/internal/infrastructure/auth/session_worker_test.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,18 @@ import (
1010
"time"
1111

1212
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
13+
"github.com/google/uuid"
1314
)
1415

16+
// mockTenantProvider for testing
17+
type mockTenantProvider struct {
18+
tenantID uuid.UUID
19+
}
20+
21+
func (m *mockTenantProvider) CurrentTenant(ctx context.Context) (uuid.UUID, error) {
22+
return m.tenantID, nil
23+
}
24+
1525
// mockSessionRepo implements SessionRepository for testing
1626
type mockSessionRepoForWorker struct {
1727
mu sync.Mutex
@@ -56,7 +66,7 @@ func TestSessionWorker_StartStop(t *testing.T) {
5666
CleanupAge: 1 * time.Hour,
5767
}
5868

59-
worker := NewSessionWorker(repo, config)
69+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
6070

6171
// Test starting
6272
err := worker.Start()
@@ -113,7 +123,7 @@ func TestSessionWorker_CleanupSuccess(t *testing.T) {
113123
CleanupAge: 24 * time.Hour,
114124
}
115125

116-
worker := NewSessionWorker(repo, config)
126+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
117127

118128
err := worker.Start()
119129
if err != nil {
@@ -147,7 +157,7 @@ func TestSessionWorker_CleanupError(t *testing.T) {
147157
CleanupAge: 24 * time.Hour,
148158
}
149159

150-
worker := NewSessionWorker(repo, config)
160+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
151161

152162
err := worker.Start()
153163
if err != nil {
@@ -181,7 +191,7 @@ func TestSessionWorker_ImmediateCleanupOnStart(t *testing.T) {
181191
CleanupAge: 24 * time.Hour,
182192
}
183193

184-
worker := NewSessionWorker(repo, config)
194+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
185195

186196
err := worker.Start()
187197
if err != nil {
@@ -228,7 +238,7 @@ func TestSessionWorker_GracefulShutdown(t *testing.T) {
228238
CleanupAge: 1 * time.Hour,
229239
}
230240

231-
worker := NewSessionWorker(repo, config)
241+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
232242

233243
err := worker.Start()
234244
if err != nil {
@@ -295,7 +305,7 @@ func TestSessionWorker_ContextCancellation(t *testing.T) {
295305
CleanupAge: 1 * time.Hour,
296306
}
297307

298-
worker := NewSessionWorker(repo, config)
308+
worker := NewSessionWorker(repo, config, nil, &mockTenantProvider{tenantID: uuid.New()})
299309

300310
err := worker.Start()
301311
if err != nil {

0 commit comments

Comments
 (0)