diff --git a/client/testfixture/migrations/U1725019360__create_admin_user.sql b/client/testfixture/migrations/U1725019360__create_admin_user.sql index 8d97b06f..f3ac413f 100644 --- a/client/testfixture/migrations/U1725019360__create_admin_user.sql +++ b/client/testfixture/migrations/U1725019360__create_admin_user.sql @@ -1,4 +1,4 @@ INSERT INTO _user (id, email, password_hash, verified, admin) VALUES - (uuid_v7(), 'admin@localhost', (hash_password('secret')), TRUE, TRUE); + (uuid_v4(), 'admin@localhost', (hash_password('secret')), TRUE, TRUE); diff --git a/client/testfixture/migrations/U1725019361__add_users.sql b/client/testfixture/migrations/U1725019361__add_users.sql index 462e0987..d74155ac 100644 --- a/client/testfixture/migrations/U1725019361__add_users.sql +++ b/client/testfixture/migrations/U1725019361__add_users.sql @@ -1,16 +1,16 @@ -- Add a a few non-admin users. INSERT INTO _user (id, email, password_hash, verified) VALUES - (uuid_v7(), '0@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '1@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '2@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '3@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '4@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '5@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '6@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '7@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '8@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '9@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '10@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '11@localhost', (hash_password('secret')), TRUE), - (uuid_v7(), '12@localhost', (hash_password('secret')), TRUE); + (uuid_v4(), '0@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '1@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '2@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '3@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '4@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '5@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '6@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '7@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '8@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '9@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '10@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '11@localhost', (hash_password('secret')), TRUE), + (uuid_v4(), '12@localhost', (hash_password('secret')), TRUE); diff --git a/trailbase-core/build.rs b/trailbase-core/build.rs index 9be53894..2afd3040 100644 --- a/trailbase-core/build.rs +++ b/trailbase-core/build.rs @@ -3,6 +3,8 @@ fn main() -> std::io::Result<()> { trailbase_build::init_env_logger(); + println!("cargo::rerun-if-changed=migrations"); + trailbase_assets::setup_version_info!(); trailbase_build::build_protos("./proto")?; diff --git a/trailbase-core/migrations/main/U3__user_id.sql b/trailbase-core/migrations/main/U3__user_id.sql new file mode 100644 index 00000000..59903ece --- /dev/null +++ b/trailbase-core/migrations/main/U3__user_id.sql @@ -0,0 +1,97 @@ +-- Migrate from UUIDv7 ids to truly random UUIDv4 ids + INTEGER primary key. +PRAGMA foreign_keys=off; + +CREATE TABLE IF NOT EXISTS _new_user ( + -- We only check `is_uuid` rather than `is_uuid_v4` to preserve user + -- previously created as uuiv7. + id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid(id)) DEFAULT (uuid_v4()), + email TEXT NOT NULL CHECK(is_email(email)), + password_hash TEXT DEFAULT '' NOT NULL, + verified INTEGER DEFAULT FALSE NOT NULL, + admin INTEGER DEFAULT FALSE NOT NULL, + + created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL, + updated INTEGER DEFAULT (UNIXEPOCH()) NOT NULL, + + -- Ephemeral data for auth flows. + -- + -- Email change/verification flow. + email_verification_code TEXT, + email_verification_code_sent_at INTEGER, + -- Change email flow. + pending_email TEXT CHECK(is_email(pending_email)), + -- Reset forgotten password flow. + password_reset_code TEXT, + password_reset_code_sent_at INTEGER, + -- Authorization Code Flow (optionally with PKCE proof key). + authorization_code TEXT, + authorization_code_sent_at INTEGER, + pkce_code_challenge TEXT, + + -- OAuth metadata + -- + -- provider_id maps to proto.config.OAuthProviderId enum. + provider_id INTEGER DEFAULT 0 NOT NULL, + -- The external provider's id for the user. + provider_user_id TEXT, + -- Link to an external avatar image for oauth providers only. + provider_avatar_url TEXT +) STRICT; + +INSERT INTO _new_user( + id, + email, + password_hash, + verified, + admin, + created, + updated, + email_verification_code, + email_verification_code_sent_at, + pending_email, + password_reset_code, + password_reset_code_sent_at, + authorization_code, + authorization_code_sent_at, + pkce_code_challenge, + provider_id, + provider_user_id, + provider_avatar_url + ) + SELECT + id, + email, + password_hash, + verified, + admin, + created, + updated, + email_verification_code, + email_verification_code_sent_at, + pending_email, + password_reset_code, + password_reset_code_sent_at, + authorization_code, + authorization_code_sent_at, + pkce_code_challenge, + provider_id, + provider_user_id, + provider_avatar_url + FROM _user; + +DROP TABLE _user; + +ALTER TABLE _new_user RENAME TO _user; + +CREATE UNIQUE INDEX __user__email_index ON _user (email); +CREATE UNIQUE INDEX __user__email_verification_code_index ON _user (email_verification_code); +CREATE UNIQUE INDEX __user__password_reset_code_index ON _user (password_reset_code); +CREATE UNIQUE INDEX __user__authorization_code_index ON _user (authorization_code); +CREATE UNIQUE INDEX __user__provider_ids_index ON _user (provider_id, provider_user_id); + +CREATE TRIGGER __user__updated_trigger AFTER UPDATE ON _user FOR EACH ROW + BEGIN + UPDATE _user SET updated = UNIXEPOCH() WHERE id = OLD.id; + END; + +PRAGMA foreign_keys=on; diff --git a/trailbase-core/src/admin/user/list_users.rs b/trailbase-core/src/admin/user/list_users.rs index b78e262c..16a3eca0 100644 --- a/trailbase-core/src/admin/user/list_users.rs +++ b/trailbase-core/src/admin/user/list_users.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::admin::AdminError as Error; use crate::app_state::AppState; use crate::auth::user::DbUser; -use crate::constants::{USER_TABLE, USER_TABLE_ID_COLUMN}; +use crate::constants::USER_TABLE; use crate::listing::{WhereClause, build_filter_where_clause, cursor_to_value, limit_or_default}; use crate::util::id_to_b64; @@ -98,7 +98,7 @@ pub async fn list_users_handler( lazy_static! { static ref DEFAULT_ORDERING: Order = Order { - columns: vec![(USER_TABLE_ID_COLUMN.to_string(), OrderPrecedent::Descending)], + columns: vec![("_rowid_".to_string(), OrderPrecedent::Descending)], }; } let users = fetch_users( @@ -165,8 +165,7 @@ async fn fetch_users( let sql_query = format!( r#" SELECT _ROW_.* - FROM - (SELECT * FROM {USER_TABLE}) as _ROW_ + FROM {USER_TABLE} as _ROW_ WHERE {where_clause} ORDER BY diff --git a/trailbase-core/src/auth/api/avatar.rs b/trailbase-core/src/auth/api/avatar.rs index 9c027c43..f6bef2bf 100644 --- a/trailbase-core/src/auth/api/avatar.rs +++ b/trailbase-core/src/auth/api/avatar.rs @@ -10,7 +10,7 @@ use crate::constants::AVATAR_TABLE; use crate::extract::Either; use crate::records::params::{JsonRow, LazyParams}; use crate::records::query_builder::QueryError; -use crate::util::{assert_uuidv7_version, uuid_to_b64}; +use crate::util::uuid_to_b64; #[utoipa::path( get, @@ -24,8 +24,6 @@ pub async fn get_avatar_handler( let Ok(user_id) = crate::util::b64_to_uuid(&b64_user_id) else { return Err(AuthError::BadRequest("Invalid user id")); }; - assert_uuidv7_version(&user_id); - let Some(table) = state.schema_metadata().get_table(&table_name) else { return Err(AuthError::Internal("missing table".into())); }; diff --git a/trailbase-core/src/auth/user.rs b/trailbase-core/src/auth/user.rs index 8e77bc6d..7e250818 100644 --- a/trailbase-core/src/auth/user.rs +++ b/trailbase-core/src/auth/user.rs @@ -44,7 +44,6 @@ pub(crate) struct DbUser { impl DbUser { pub(crate) fn uuid(&self) -> Uuid { let uuid = Uuid::from_bytes(self.id); - assert_eq!(uuid.get_version_num(), 7); return uuid; } @@ -108,7 +107,6 @@ impl User { pub(crate) fn from_token_claims(claims: TokenClaims) -> Result { let uuid = b64_to_uuid(&claims.sub) .map_err(|_err| AuthError::UnauthorizedExt("invalid user id".into()))?; - assert_eq!(uuid.get_version_num(), 7); return Ok(Self { id: claims.sub, diff --git a/trailbase-core/src/constants.rs b/trailbase-core/src/constants.rs index ad229533..3d116ed6 100644 --- a/trailbase-core/src/constants.rs +++ b/trailbase-core/src/constants.rs @@ -2,7 +2,6 @@ use chrono::Duration; pub const SQLITE_SCHEMA_TABLE: &str = "sqlite_schema"; pub const USER_TABLE: &str = "_user"; -pub(crate) const USER_TABLE_ID_COLUMN: &str = "id"; pub(crate) const SESSION_TABLE: &str = "_session"; pub(crate) const AVATAR_TABLE: &str = "_user_avatar"; diff --git a/trailbase-core/src/records/create_record.rs b/trailbase-core/src/records/create_record.rs index e1cf12b2..b0a2e427 100644 --- a/trailbase-core/src/records/create_record.rs +++ b/trailbase-core/src/records/create_record.rs @@ -213,14 +213,13 @@ mod test { state .conn() - .execute( + .execute_batch( r#" CREATE TABLE simple ( owner BLOB PRIMARY KEY CHECK(is_uuid(owner)) REFERENCES _user, value INTEGER ) STRICT; "#, - (), ) .await .unwrap(); @@ -291,7 +290,7 @@ mod test { User::from_auth_token(&state, &user_x_token.auth_token), Either::Json( json_row_from_value(json!({ - "owner": uuid_to_b64(&uuid::Uuid::now_v7()), + "owner": uuid_to_b64(&uuid::Uuid::new_v4()), "value": 17, })) .unwrap() diff --git a/trailbase-extension/src/lib.rs b/trailbase-extension/src/lib.rs index 5218c71f..13c0fd43 100644 --- a/trailbase-extension/src/lib.rs +++ b/trailbase-extension/src/lib.rs @@ -103,12 +103,20 @@ pub fn sqlite3_extension_init( FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS, uuid::is_uuid, )?; + db.create_scalar_function( + "is_uuid_v4", + 1, + FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS, + uuid::is_uuid_v4, + )?; + db.create_scalar_function("uuid_v4", 0, FunctionFlags::SQLITE_INNOCUOUS, uuid::uuid_v4)?; db.create_scalar_function( "is_uuid_v7", 1, FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_INNOCUOUS, uuid::is_uuid_v7, )?; + db.create_scalar_function("uuid_v7", 0, FunctionFlags::SQLITE_INNOCUOUS, uuid::uuid_v7)?; db.create_scalar_function( "uuid_text", 1, @@ -116,7 +124,6 @@ pub fn sqlite3_extension_init( uuid::uuid_text, )?; - db.create_scalar_function("uuid_v7", 0, FunctionFlags::SQLITE_INNOCUOUS, uuid::uuid_v7)?; db.create_scalar_function( "uuid_parse", 1, diff --git a/trailbase-extension/src/uuid.rs b/trailbase-extension/src/uuid.rs index d6c8d792..fb84a3b6 100644 --- a/trailbase-extension/src/uuid.rs +++ b/trailbase-extension/src/uuid.rs @@ -9,6 +9,22 @@ pub(super) fn is_uuid(context: &Context) -> Result { return Ok(unpack_uuid_or_null(context).is_ok()); } +/// Checks that argument is a valid UUIDv4 blob or null. +/// +/// Null is explicitly allowed to enable use as CHECK constraint in nullable columns. +pub(super) fn is_uuid_v4(context: &Context) -> Result { + let Some(uuid) = unpack_uuid_or_null(context)? else { + return Ok(true); + }; + + return Ok(uuid.get_version_num() == 7); +} + +/// Creates a new UUIDv4 blob. +pub(super) fn uuid_v4(_context: &Context) -> Result<[u8; 16], Error> { + return Ok(Uuid::new_v4().into_bytes()); +} + /// Checks that argument is a valid UUIDv7 blob or null. /// /// Null is explicitly allowed to enable use as CHECK constraint in nullable columns. @@ -21,8 +37,8 @@ pub(super) fn is_uuid_v7(context: &Context) -> Result { } /// Creates a new UUIDv7 blob. -pub(super) fn uuid_v7(_context: &Context) -> Result, Error> { - return Ok(Uuid::now_v7().as_bytes().to_vec()); +pub(super) fn uuid_v7(_context: &Context) -> Result<[u8; 16], Error> { + return Ok(Uuid::now_v7().into_bytes()); } /// Format UUID blob as string-encoded UUID. @@ -38,7 +54,7 @@ pub(super) fn uuid_text(context: &Context) -> Result { } /// Parse UUID from string-encoded UUID. -pub(super) fn uuid_parse(context: &Context) -> Result, Error> { +pub(super) fn uuid_parse(context: &Context) -> Result<[u8; 16], Error> { #[cfg(debug_assertions)] if context.len() != 1 { return Err(Error::InvalidParameterCount(context.len(), 1)); @@ -46,7 +62,7 @@ pub(super) fn uuid_parse(context: &Context) -> Result, Error> { let str = context.get_raw(0).as_str()?; let uuid = Uuid::try_parse(str).map_err(|err| Error::UserFunctionError(err.into()))?; - return Ok(uuid.into_bytes().into()); + return Ok(uuid.into_bytes()); } #[inline]