Menu
Public repository / 26d50ee76eb4c6c0aaca914e91a507fae2a243a6
AkurAI-IDP / src/lib/organizations.rs
Latest change 26d50ee76eb4c6c0aaca914e91a507fae2a243a6 - feat: add tenant-safe organization identity by Ólafur Búi Ólafsson
use rusqlite::{Connection, OptionalExtension};
/// Version of the organization claims emitted by this IDP.
pub const CLAIMS_VERSION: i64 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Membership {
pub organization_id: String,
pub user_id: String,
pub role: String,
pub authorization_version: i64,
}
pub fn active_membership(
conn: &Connection,
user_id: &str,
organization_id: &str,
) -> rusqlite::Result<Option<Membership>> {
conn.query_row(
"SELECT m.organization_id, m.user_id, m.role, u.authorization_version
FROM organization_memberships m
JOIN users u ON u.id = m.user_id
WHERE m.organization_id = ?1 AND m.user_id = ?2 AND m.status = 'active'",
rusqlite::params![organization_id, user_id],
|row| {
Ok(Membership {
organization_id: row.get(0)?,
user_id: row.get(1)?,
role: row.get(2)?,
authorization_version: row.get(3)?,
})
},
)
.optional()
}
pub fn user_authorization_version(conn: &Connection, user_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT authorization_version FROM users WHERE id = ?1",
[user_id],
|row| row.get(0),
)
}
/// Invalidate every existing session and token for a user. JWTs carry this
/// version and are checked against the authoritative row on every request.
pub fn bump_authorization_version(conn: &Connection, user_id: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET authorization_version = authorization_version + 1, updated_at = ?1
WHERE id = ?2",
rusqlite::params![chrono::Utc::now().timestamp(), user_id],
)?;
Ok(())
}
pub fn valid_role(role: &str) -> bool {
matches!(role, "owner" | "admin" | "member")
}
pub fn can_administer(role: &str) -> bool {
matches!(role, "owner" | "admin")
}
pub fn normalize_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}
/// Keep the legacy primary-organization alias usable when a user leaves or
/// loses access to that organization. Secondary memberships remain
/// authoritative and do not get copied into tokens.
pub fn move_primary_organization_if_needed(
conn: &Connection,
user_id: &str,
organization_id: &str,
) -> rusqlite::Result<bool> {
let primary: Option<String> = conn
.query_row(
"SELECT tenant_id FROM users WHERE id = ?1",
[user_id],
|row| row.get(0),
)
.optional()?;
if primary.as_deref() != Some(organization_id) {
return Ok(false);
}
let replacement: Option<String> = conn
.query_row(
"SELECT organization_id FROM organization_memberships
WHERE user_id = ?1 AND status = 'active' AND organization_id <> ?2
ORDER BY created_at, organization_id LIMIT 1",
rusqlite::params![user_id, organization_id],
|row| row.get(0),
)
.optional()?;
let Some(replacement) = replacement else {
return Ok(false);
};
conn.execute(
"UPDATE users SET tenant_id = ?, updated_at = ? WHERE id = ?",
rusqlite::params![replacement, chrono::Utc::now().timestamp(), user_id],
)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::schema::create_tables;
fn fixture() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
create_tables(&conn);
conn.execute(
"INSERT INTO tenants (id, name, slug, created_at) VALUES ('org-a', 'A', 'a', 1)",
[],
)
.expect("tenant");
conn.execute(
"INSERT INTO users
(id, tenant_id, email, password_hash, created_at, updated_at)
VALUES ('user-a', 'org-a', 'a@example.test', 'hash', 1, 1)",
[],
)
.expect("user");
create_tables(&conn);
conn
}
#[test]
fn display_name_and_email_changes_preserve_canonical_ids() {
let conn = fixture();
let before = active_membership(&conn, "user-a", "org-a")
.expect("lookup")
.expect("membership");
conn.execute(
"UPDATE organizations SET display_name = 'Renamed' WHERE id = 'org-a'",
[],
)
.expect("organization rename");
conn.execute(
"UPDATE users SET email = 'new@example.test' WHERE id = 'user-a'",
[],
)
.expect("email rename");
let after = active_membership(&conn, "user-a", "org-a")
.expect("lookup")
.expect("membership");
assert_eq!(before.organization_id, after.organization_id);
assert_eq!(before.user_id, after.user_id);
}
#[test]
fn membership_lookup_is_tenant_bound() {
let conn = fixture();
conn.execute(
"INSERT INTO organizations (id, display_name, slug, created_at, updated_at)
VALUES ('org-b', 'B', 'b', 1, 1)",
[],
)
.expect("second organization");
assert!(
active_membership(&conn, "user-a", "org-b")
.expect("lookup")
.is_none()
);
}
#[test]
fn authorization_version_invalidates_stale_membership() {
let conn = fixture();
let before = active_membership(&conn, "user-a", "org-a")
.expect("lookup")
.expect("membership");
bump_authorization_version(&conn, "user-a").expect("bump");
let after = active_membership(&conn, "user-a", "org-a")
.expect("lookup")
.expect("membership");
assert_ne!(before.authorization_version, after.authorization_version);
}
#[test]
fn revoked_membership_is_not_authorized() {
let conn = fixture();
conn.execute(
"UPDATE organization_memberships SET status = 'revoked'
WHERE organization_id = 'org-a' AND user_id = 'user-a'",
[],
)
.expect("revoke membership");
assert!(
active_membership(&conn, "user-a", "org-a")
.expect("lookup")
.is_none()
);
}
}