Use UUIDv4 ids for _users to stop leaking creation times. We rely on _rowid_ for cursoring.

This commit is contained in:
Sebastian Jeltsch
2025-06-10 22:45:58 +02:00
parent bf3732beb2
commit 39eec6b980
11 changed files with 147 additions and 32 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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")?;

View File

@@ -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;

View File

@@ -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

View File

@@ -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()));
};

View File

@@ -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<Self, AuthError> {
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,

View File

@@ -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";

View File

@@ -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()

View File

@@ -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,

View File

@@ -9,6 +9,22 @@ pub(super) fn is_uuid(context: &Context) -> Result<bool, Error> {
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<bool, Error> {
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<bool, Error> {
}
/// Creates a new UUIDv7 blob.
pub(super) fn uuid_v7(_context: &Context) -> Result<Vec<u8>, 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<String, Error> {
}
/// Parse UUID from string-encoded UUID.
pub(super) fn uuid_parse(context: &Context) -> Result<Vec<u8>, 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<Vec<u8>, 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]