6.7 KiB
Password Hash Column Fix
Date: 2026-01-18
Status: ✅ Complete
Priority: P1 - Security/Data Model Integrity
Summary
Fixed the authentication system to properly use the dedicated password_hash column on the identity table instead of storing password hashes in the attributes JSON field. This is a critical fix for data model integrity and performance.
Problem
The API authentication system was storing password hashes in the attributes JSONB field:
{
"email": "user@example.com",
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$..."
}
Issues:
- ❌ Dedicated
password_hashcolumn in database schema was unused - ❌ Querying on JSON fields is slower than indexed columns
- ❌ Password hashes mixed with other user metadata
- ❌ Inconsistent with database schema design
- ❌ Type safety concerns with JSON field access
Solution
Updated all authentication code to use the password_hash column directly:
1. Model Changes
File: crates/common/src/models.rs
pub struct Identity {
pub id: Id,
pub login: String,
pub display_name: Option<String>,
pub password_hash: Option<String>, // ✅ Added
pub attributes: JsonDict,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
2. Repository Changes
File: crates/common/src/repositories/identity.rs
CreateIdentityInput:
pub struct CreateIdentityInput {
pub login: String,
pub display_name: Option<String>,
pub password_hash: Option<String>, // ✅ Added
pub attributes: JsonDict,
}
UpdateIdentityInput:
pub struct UpdateIdentityInput {
pub display_name: Option<String>,
pub password_hash: Option<String>, // ✅ Added
pub attributes: Option<JsonDict>,
}
All SQL queries updated to include password_hash column:
find_by_id- Added to SELECTfind_by_login- Added to SELECTlist- Added to SELECTcreate- Added to INSERTupdate- Added to UPDATE with query builder
3. API Changes
File: crates/api/src/routes/auth.rs
Login Endpoint:
// Before: Reading from attributes JSON
let password_hash = identity
.attributes
.get("password_hash")
.and_then(|v| v.as_str())
.ok_or_else(...)?;
// After: Reading from column
let password_hash = identity
.password_hash
.as_ref()
.ok_or_else(...)?;
Register Endpoint:
// Before: Storing in attributes JSON
let mut attrs = serde_json::Map::new();
attrs.insert("password_hash".to_string(), json!(password_hash));
// After: Storing in column
let input = CreateIdentityInput {
login: payload.login.clone(),
display_name: payload.display_name,
password_hash: Some(password_hash), // ✅ Column
attributes: serde_json::json!({}),
};
Change Password Endpoint:
// Before: Raw SQL update of attributes
sqlx::query("UPDATE identities SET attributes = $1...")
.bind(&attributes)
.execute(&state.db)
.await?;
// After: Using repository with proper input
let update_input = UpdateIdentityInput {
display_name: None,
password_hash: Some(new_password_hash), // ✅ Column
attributes: None,
};
IdentityRepository::update(&state.db, identity_id, update_input).await?;
4. Setup Script Changes
File: scripts/setup-e2e-db.sh
-- Before: Hash in attributes JSON
INSERT INTO attune.identity (login, display_name, attributes)
VALUES (
'e2e_test_user',
'E2E Test User',
jsonb_build_object('password_hash', '$HASH', ...)
);
-- After: Hash in column
INSERT INTO attune.identity (login, display_name, password_hash, attributes)
VALUES (
'e2e_test_user',
'E2E Test User',
'$HASH', -- ✅ Dedicated column
jsonb_build_object('email', 'e2e@test.local', ...)
);
Files Modified
crates/common/src/models.rs- Addedpassword_hashfield toIdentitycrates/common/src/repositories/identity.rs- Updated all queries and inputscrates/api/src/routes/auth.rs- Fixed login, register, change_passwordscripts/setup-e2e-db.sh- Fixed test user creation
Verification
Database Schema
-- Verify password_hash column usage
SELECT
id,
login,
password_hash IS NOT NULL as has_hash,
attributes ? 'password_hash' as hash_in_attrs
FROM attune.identity;
-- Result:
-- id | login | has_hash | hash_in_attrs
-- 1 | e2e_test_user | t | f ✅
-- 2 | test_user_2 | t | f ✅
Authentication Tests
Login Test:
curl -X POST http://127.0.0.1:18080/auth/login \
-H 'Content-Type: application/json' \
-d '{"login":"e2e_test_user","password":"test_password_123"}'
# ✅ Returns JWT tokens
Register Test:
curl -X POST http://127.0.0.1:18080/auth/register \
-H 'Content-Type: application/json' \
-d '{"login":"test_user_2","password":"password123","display_name":"Test User 2"}'
# ✅ Creates user with password_hash in column
Unit Tests
cargo test -p attune-common --lib
# ✅ 96 tests passed
cargo test -p attune-api --lib
# ✅ 46 tests passed
Benefits
- Performance: Direct column access is faster than JSON field extraction
- Indexing: Can create index on
password_hashcolumn if needed - Type Safety: Rust type system enforces
Option<String>instead of JSON value - Clarity: Password hash storage is explicit in the data model
- Consistency: Aligns with database schema design
- Separation: Authentication data separate from user metadata
Migration Notes
For Production Deployments:
If you have existing identities with passwords in attributes, run this migration:
-- Move password_hash from attributes to column
UPDATE attune.identity
SET password_hash = attributes->>'password_hash'
WHERE attributes ? 'password_hash'
AND password_hash IS NULL;
-- Clean up attributes (optional)
UPDATE attune.identity
SET attributes = attributes - 'password_hash'
WHERE attributes ? 'password_hash';
For Development/Testing:
Simply recreate the E2E database:
./scripts/setup-e2e-db.sh
Security Considerations
- ✅ Password hashes remain Argon2id encrypted
- ✅ Same verification logic (no functional changes)
- ✅ Column is
Option<String>(nullable) for identities without passwords - ✅ All existing security measures preserved
- ✅ No plaintext passwords ever stored
Related Files
- Database schema:
migrations/20250101000001_initial_setup.sql - Identity model:
crates/common/src/models.rs - Identity repository:
crates/common/src/repositories/identity.rs - Auth routes:
crates/api/src/routes/auth.rs - E2E setup:
scripts/setup-e2e-db.sh
Status: Production-ready. All tests passing. Authentication verified with proper column usage.