CalDAV principals: rooms and resources leave the users table
- New `principals` table owns collections; every account gets one via an insert trigger, rooms and resources have no account row at all - One name space for accounts and rooms, enforced by `principals.name` - Account queries drop their room filters; logins and the admin user API cannot reach a room structurally - CalDAV schema v12 to v14 folded into one v12 migration (never shipped); master's v11 upgrades in one step and backfills principals - Tests: rooms never authenticate, shared name space, user deletion cascades to principal, collections and loans, upgrade creates principals Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mserver/src/api/admin.rs
@@ -95,7 +95,8 @@ pub async fn create_user(
let name = body.name.trim().to_string();
validate_account_name(&name)?;
validate_password(&body.password)?;
if state.db.find_user_by_name(&name).await?.is_some() {
// Rooms and resources share the name space.
if state.db.name_taken(&name).await? {
return Err(ApiError::localized(
StatusCode::CONFLICT,
"a user with that name already exists",
Mserver/src/api/pim.rs
@@ -108,6 +108,8 @@ pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> R
/// The signed-in account.
struct Me {
id: i64,
/// The account's principal, which owns its collections.
pid: i64,
admin: bool,
/// The own principal href. Spelled as the request spelled the name when
/// it named this account: a client that asked for `/ALICE/` must get
@@ -206,7 +208,7 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
Ok(v) => v,
Err(code) => return Ok(status(code)),
};
state.db.pim_ensure_defaults(me.id).await?;
state.db.pim_ensure_defaults(me.pid).await?;
let method = req.method().clone();
let (parts, body) = req.into_parts();
@@ -238,6 +240,7 @@ async fn resolve_space(
) -> Result<Result<(Me, Option<Space>), StatusCode>, ApiError> {
let mut me = Me {
id: user.id,
pid: state.db.principal_of(user.id).await?,
admin: user.is_admin,
principal: principal_href(&user.name),
};
@@ -247,7 +250,7 @@ async fn resolve_space(
if segment.eq_ignore_ascii_case(&user.name) {
me.principal = principal_href(segment);
let space = Space {
id: user.id,
id: me.pid,
path: segment.to_string(),
display: user.name.clone(),
kind: UserType::Individual,
@@ -451,7 +454,7 @@ impl PrincipalView {
path: p.name.clone(),
display: p.display().to_string(),
kind: p.kind,
me: p.id == me.id,
me: p.id == me.pid,
}
}
@@ -1229,7 +1232,7 @@ impl Cx<'_> {
if !self
.state
.db
.pim_create_collection(self.me.id, *kind, &col)
.pim_create_collection(self.me.pid, *kind, &col)
.await?
{
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
Mserver/src/api/pim_api.rs
@@ -13,7 +13,7 @@ use axum::http::StatusCode;
use crate::api::common::SessionUser;
use crate::api::pim::{INBOX, collection_href};
use crate::db::{PimCollection, PimKind, UserType};
use crate::db::{PimCollection, PimKind};
use crate::error::{ApiError, AppState};
fn wire_kind(kind: PimKind) -> PimCollectionKind {
@@ -33,10 +33,11 @@ pub async fn list(
auth: SessionUser,
) -> Result<Json<Vec<PimCollectionInfo>>, ApiError> {
let me = &auth.user;
state.db.pim_ensure_defaults(me.id).await?;
let pid = state.db.principal_of(me.id).await?;
state.db.pim_ensure_defaults(pid).await?;
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for c in state.db.pim_collections(me.id, kind).await? {
for c in state.db.pim_collections(pid, kind).await? {
if kind == PimKind::Calendar && c.slug == INBOX {
continue;
}
@@ -65,9 +66,10 @@ pub async fn list(
/// The id of a collection the signed-in user owns, or 404.
async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
let pid = state.db.principal_of(auth.user.id).await?;
match state.db.pim_collection_by_id(id).await? {
// The inbox is not lent: it holds messages, not events.
Some((owner, _, c)) if owner == auth.user.id && c.slug != INBOX => Ok(id),
Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id),
_ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
}
}
@@ -105,17 +107,19 @@ pub async fn share(
.db
.pim_principal(body.user.trim())
.await?
.filter(|p| p.kind == UserType::Individual)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
if user.id == auth.user.id {
let Some(user_id) = user.user_id else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
};
if user_id == auth.user.id {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"a collection cannot be shared with its owner",
));
}
state.db.pim_set_share(id, user.id, body.mode).await?;
state.db.pim_set_share(id, user_id, body.mode).await?;
Ok(Json(PimShareInfo {
user_id: user.id,
user_id,
user_name: user.name,
mode: body.mode,
}))
Mserver/src/api/pim_schedule.rs
@@ -368,7 +368,7 @@ fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
);
let name = format!("{}.ics", &crate::hex(&Sha256::digest(seed))[..32]);
PimOp::Inbox {
user_id: p.id,
principal_id: p.id,
obj: PimObject {
// Inbox messages share UIDs, and the store keeps UIDs unique.
uid: name.clone(),
Mserver/src/db.rs
@@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 14;
const SCHEMA_VERSION: i64 = 12;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -219,10 +219,10 @@ pub enum PimOp {
collection_id: i64,
name: String,
},
/// A scheduling message for the inbox of `user_id`. `obj.uid` must be
/// unique in the inbox: several messages share one iCalendar UID.
/// A scheduling message for the inbox of `principal_id`. `obj.uid` must
/// be unique in the inbox: several messages share one iCalendar UID.
Inbox {
user_id: i64,
principal_id: i64,
obj: PimObject,
data: Vec<u8>,
},
@@ -418,13 +418,37 @@ impl Db {
)?;
}
if version < 12 {
// CalDAV and CardDAV. `seq` counts every change to a collection
// and its members; `pim_changes` keeps the latest change per
// member, deletions included, for sync tokens.
// CalDAV and CardDAV. A principal owns collections: every account
// has one, and rooms and resources are principals without an
// account, so they share the account name space but no account
// query can return them. The trigger gives each new account its
// principal and refuses a name a room already has.
//
// `seq` counts every change to a collection and its members;
// `pim_changes` keeps the latest change per member, deletions
// included, for sync tokens. `pim_shares` lends a collection to
// another account. `schedule_tag` is NULL for objects that
// schedule nothing.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pim_collections (
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('person','room','resource')),
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT,
CHECK ((kind = 'person') = (user_id IS NOT NULL))
);
INSERT INTO principals (user_id, kind, name)
SELECT id, 'person', name FROM users;
CREATE TRIGGER IF NOT EXISTS principal_of_user AFTER INSERT ON users
BEGIN
INSERT INTO principals (user_id, kind, name)
VALUES (NEW.id, 'person', NEW.name);
END;
CREATE TABLE IF NOT EXISTS pim_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
principal_id INTEGER NOT NULL
REFERENCES principals(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('cal','card')),
slug TEXT NOT NULL,
displayname TEXT,
@@ -435,7 +459,7 @@ impl Db {
components TEXT NOT NULL DEFAULT '',
seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (user_id, kind, slug)
UNIQUE (principal_id, kind, slug)
);
CREATE TABLE IF NOT EXISTS pim_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -447,6 +471,7 @@ impl Db {
data BLOB NOT NULL,
etag TEXT NOT NULL,
modified_at TEXT NOT NULL,
schedule_tag TEXT,
UNIQUE (collection_id, name),
UNIQUE (collection_id, uid)
);
@@ -459,17 +484,7 @@ impl Db {
PRIMARY KEY (collection_id, name)
);
CREATE INDEX IF NOT EXISTS idx_pim_changes_seq
ON pim_changes(collection_id, seq);",
)?;
}
if version < 13 {
// Rooms and resources are rows of `users` that cannot sign in, so
// they share the name space and the collection ownership of
// accounts. `pim_shares` lends a collection to another account.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'person'
CHECK (kind IN ('person','room','resource'));
ALTER TABLE users ADD COLUMN display_name TEXT;
ON pim_changes(collection_id, seq);
CREATE TABLE IF NOT EXISTS pim_shares (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
@@ -480,11 +495,6 @@ impl Db {
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);",
)?;
}
if version < 14 {
// Implicit scheduling (RFC 6638). NULL for objects that schedule
// nothing.
conn.execute_batch("ALTER TABLE pim_objects ADD COLUMN schedule_tag TEXT;")?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -533,9 +543,7 @@ impl Db {
let row: Option<(User, String)> = {
let c = self.0.lock().await;
c.query_row(
&format!(
"SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1 AND kind = 'person'"
),
&format!("SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1"),
[name],
|r| Ok((map_user(r)?, r.get(USER_COL_COUNT)?)),
)
@@ -630,7 +638,6 @@ impl Db {
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
WHERE u.kind = 'person'
ORDER BY u.id, r.id",
))?;
// Rows arrive grouped by user, so a new user id starts a new group.
@@ -655,14 +662,13 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1 AND kind = 'person'"),
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"),
[id],
map_user,
)
.optional()
}
/// Rooms and resources too, since they share the name space.
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
@@ -858,7 +864,7 @@ impl Db {
pub async fn find_user_by_webauthn_id(&self, wid: &Uuid) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1 AND kind = 'person'"),
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1"),
[wid.to_string()],
map_user,
)
@@ -1318,56 +1324,56 @@ impl Db {
// ---------- CalDAV and CardDAV ----------
/// Gives the user a calendar, an address book and a scheduling inbox when
/// they have none.
pub async fn pim_ensure_defaults(&self, user_id: i64) -> DbResult<()> {
/// Gives an account's principal a calendar, an address book and a
/// scheduling inbox when it has none.
pub async fn pim_ensure_defaults(&self, principal_id: i64) -> DbResult<()> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
"INSERT INTO pim_collections (principal_id, kind, slug, displayname, components, created_at)
SELECT ?1, ?2, 'default', ?3, ?4, ?5
WHERE NOT EXISTS (SELECT 1 FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 AND slug != 'inbox')",
WHERE principal_id = ?1 AND kind = ?2 AND slug != 'inbox')",
)?;
let now = now();
stmt.execute(params![
user_id,
principal_id,
"cal",
"Calendar",
"VEVENT,VTODO,VJOURNAL",
now
])?;
stmt.execute(params![user_id, "card", "Contacts", "", now])?;
ensure_inbox(&c, user_id)
stmt.execute(params![principal_id, "card", "Contacts", "", now])?;
ensure_inbox(&c, principal_id)
}
/// The scheduling inbox alone, for rooms and resources.
pub async fn pim_ensure_inbox(&self, user_id: i64) -> DbResult<()> {
pub async fn pim_ensure_inbox(&self, principal_id: i64) -> DbResult<()> {
let c = self.0.lock().await;
ensure_inbox(&c, user_id)
ensure_inbox(&c, principal_id)
}
/// The calendar that receives new invitations of `component`: the
/// oldest one that takes it.
pub async fn pim_calendar_for(
&self,
user_id: i64,
principal_id: i64,
component: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug != 'inbox'
WHERE principal_id = ?1 AND kind = 'cal' AND slug != 'inbox'
AND ',' || components || ',' LIKE '%,' || ?2 || ',%'
ORDER BY id LIMIT 1"
))?;
stmt.query_row(params![user_id, component], map_pim_collection)
stmt.query_row(params![principal_id, component], map_pim_collection)
.optional()
}
/// The object with `uid` in any of the user's own calendars.
pub async fn pim_find_uid(
&self,
user_id: i64,
principal_id: i64,
uid: &str,
) -> DbResult<Option<(i64, PimObject, Vec<u8>)>> {
let c = self.0.lock().await;
@@ -1375,10 +1381,10 @@ impl Db {
"SELECT o.name, o.uid, o.component, o.etag, length(o.data), o.modified_at,
o.schedule_tag, o.data, o.collection_id
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.user_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox' AND o.uid = ?2
WHERE c.principal_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox' AND o.uid = ?2
ORDER BY o.id LIMIT 1",
)?;
stmt.query_row(params![user_id, uid], |r| {
stmt.query_row(params![principal_id, uid], |r| {
Ok((r.get(8)?, map_pim_object(r)?, r.get(7)?))
})
.optional()
@@ -1423,11 +1429,15 @@ impl Db {
)?;
record_pim_change(&tx, *collection_id, name, true)?;
}
PimOp::Inbox { user_id, obj, data } => {
PimOp::Inbox {
principal_id,
obj,
data,
} => {
let inbox: i64 = tx.query_row(
"SELECT id FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
[user_id],
WHERE principal_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
[principal_id],
|r| r.get(0),
)?;
put_object(&tx, inbox, obj, data)?;
@@ -1456,47 +1466,50 @@ impl Db {
pub async fn pim_collections(
&self,
user_id: i64,
principal_id: i64,
kind: PimKind,
) -> DbResult<Vec<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 ORDER BY id"
WHERE principal_id = ?1 AND kind = ?2 ORDER BY id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_pim_collection)?
stmt.query_map(params![principal_id, kind.as_str()], map_pim_collection)?
.collect()
}
pub async fn pim_collection(
&self,
user_id: i64,
principal_id: i64,
kind: PimKind,
slug: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE user_id = ?1 AND kind = ?2 AND slug = ?3"
WHERE principal_id = ?1 AND kind = ?2 AND slug = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), slug], map_pim_collection)
.optional()
stmt.query_row(
params![principal_id, kind.as_str(), slug],
map_pim_collection,
)
.optional()
}
/// `false` if the slug is taken. `id` and `seq` of `new` are ignored.
pub async fn pim_create_collection(
&self,
user_id: i64,
principal_id: i64,
kind: PimKind,
new: &PimCollection,
) -> DbResult<bool> {
let c = self.0.lock().await;
let n = c.execute(
"INSERT OR IGNORE INTO pim_collections (user_id, kind, slug, displayname,
"INSERT OR IGNORE INTO pim_collections (principal_id, kind, slug, displayname,
description, color, timezone, sort_order, components, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
user_id,
principal_id,
kind.as_str(),
new.slug,
new.displayname,
@@ -1663,7 +1676,7 @@ impl Db {
pub async fn pim_principal(&self, name: &str) -> DbResult<Option<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE name = ?1 AND {VISIBLE}"
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.name = ?1 AND {VISIBLE}"
))?;
stmt.query_row([name], map_principal).optional()
}
@@ -1671,11 +1684,25 @@ impl Db {
pub async fn pim_principals(&self) -> DbResult<Vec<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE {VISIBLE} ORDER BY id"
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE {VISIBLE} ORDER BY p.id"
))?;
stmt.query_map([], map_principal)?.collect()
}
/// The principal of an account.
pub async fn principal_of(&self, user_id: i64) -> DbResult<i64> {
let c = self.0.lock().await;
c.prepare_cached("SELECT id FROM principals WHERE user_id = ?1")?
.query_row([user_id], |r| r.get(0))
}
/// Whether an account, room or resource has this name.
pub async fn name_taken(&self, name: &str) -> DbResult<bool> {
let c = self.0.lock().await;
c.prepare_cached("SELECT EXISTS (SELECT 1 FROM principals WHERE name = ?1)")?
.query_row([name], |r| r.get(0))
}
/// The collection `collection_id` as lent to `user_id`, with its owner's
/// name and the mode.
pub async fn pim_shared_collection(
@@ -1686,10 +1713,10 @@ impl Db {
) -> DbResult<Option<(PimCollection, String, Mode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, u.name, s.mode
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN users u ON u.id = c.user_id
JOIN principals p ON p.id = c.principal_id
WHERE s.user_id = ?1 AND c.kind = ?2 AND c.id = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), collection_id], map_shared)
@@ -1704,10 +1731,10 @@ impl Db {
) -> DbResult<Vec<(PimCollection, String, Mode)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, u.name, s.mode
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN users u ON u.id = c.user_id
JOIN principals p ON p.id = c.principal_id
WHERE s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_shared)?
@@ -1721,7 +1748,7 @@ impl Db {
) -> DbResult<Option<(i64, PimKind, PimCollection)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS}, user_id, kind FROM pim_collections WHERE id = ?1"
"SELECT {PIM_COLLECTION_COLS}, principal_id, kind FROM pim_collections WHERE id = ?1"
))?;
stmt.query_row([id], |r| {
Ok((
@@ -1773,7 +1800,7 @@ impl Db {
pub async fn rooms(&self) -> DbResult<Vec<PimPrincipal>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM users WHERE kind != 'person' ORDER BY name"
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.user_id IS NULL ORDER BY p.name"
))?;
stmt.query_map([], map_principal)?.collect()
}
@@ -1788,25 +1815,24 @@ impl Db {
) -> DbResult<Option<PimPrincipal>> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
// No password and never active: no sign-in path accepts it.
let inserted = tx.execute(
"INSERT INTO users (name, pass_hash, is_admin, active, created_at, kind, display_name)
SELECT ?1, '', 0, 0, ?2, ?3, ?4
WHERE NOT EXISTS (SELECT 1 FROM users WHERE name = ?1)",
params![name, now(), kind_str(kind), display_name],
"INSERT INTO principals (kind, name, display_name)
SELECT ?1, ?2, ?3 WHERE NOT EXISTS (SELECT 1 FROM principals WHERE name = ?2)",
params![kind_str(kind), name, display_name],
)?;
if inserted == 0 {
return Ok(None);
}
let id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
"INSERT INTO pim_collections (principal_id, kind, slug, displayname, components, created_at)
VALUES (?1, 'cal', 'default', ?2, 'VEVENT', ?3)",
params![id, display_name, now()],
)?;
tx.commit()?;
Ok(Some(PimPrincipal {
id,
user_id: None,
name: name.to_string(),
display_name: Some(display_name.to_string()),
kind,
@@ -1816,14 +1842,17 @@ impl Db {
pub async fn set_room_display_name(&self, id: i64, display_name: &str) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute(
"UPDATE users SET display_name = ?2 WHERE id = ?1 AND kind != 'person'",
"UPDATE principals SET display_name = ?2 WHERE id = ?1 AND user_id IS NULL",
params![id, display_name],
)? > 0)
}
pub async fn delete_room(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM users WHERE id = ?1 AND kind != 'person'", [id])? > 0)
Ok(c.execute(
"DELETE FROM principals WHERE id = ?1 AND user_id IS NULL",
[id],
)? > 0)
}
/// Whether users may create writable (read-write) shares. Off by default;
@@ -1942,14 +1971,14 @@ fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordInfo> {
/// Messages an inbox keeps; older ones are dropped.
const INBOX_KEEP: i64 = 100;
fn ensure_inbox(c: &Connection, user_id: i64) -> DbResult<()> {
fn ensure_inbox(c: &Connection, principal_id: i64) -> DbResult<()> {
c.prepare_cached(
"INSERT INTO pim_collections (user_id, kind, slug, displayname, components, created_at)
"INSERT INTO pim_collections (principal_id, kind, slug, displayname, components, created_at)
SELECT ?1, 'cal', 'inbox', 'Inbox', 'VEVENT,VTODO,VJOURNAL', ?2
WHERE NOT EXISTS (SELECT 1 FROM pim_collections
WHERE user_id = ?1 AND kind = 'cal' AND slug = 'inbox')",
WHERE principal_id = ?1 AND kind = 'cal' AND slug = 'inbox')",
)?
.execute(params![user_id, now()])?;
.execute(params![principal_id, now()])?;
Ok(())
}
@@ -2000,10 +2029,12 @@ fn record_pim_change(
Ok(())
}
/// A signed-in-capable account or a room, as CalDAV sees it.
/// An account, room or resource, as CalDAV sees it.
#[derive(Debug, Clone)]
pub struct PimPrincipal {
pub id: i64,
/// The account of a person; rooms and resources have none.
pub user_id: Option<i64>,
/// The URL segment.
pub name: String,
pub display_name: Option<String>,
@@ -2016,16 +2047,18 @@ impl PimPrincipal {
}
}
const PRINCIPAL_COLS: &str = "id, name, display_name, kind";
/// Rooms are never active; disabled accounts are hidden.
const VISIBLE: &str = "(kind != 'person' OR active = 1)";
const PRINCIPAL_COLS: &str = "p.id, p.user_id, p.name, p.display_name, p.kind";
const PRINCIPALS: &str = "principals p LEFT JOIN users u ON u.id = p.user_id";
/// Disabled accounts are hidden.
const VISIBLE: &str = "(p.user_id IS NULL OR u.active = 1)";
fn map_principal(r: &rusqlite::Row) -> DbResult<PimPrincipal> {
Ok(PimPrincipal {
id: r.get(0)?,
name: r.get(1)?,
display_name: r.get(2)?,
kind: match r.get::<_, String>(3)?.as_str() {
user_id: r.get(1)?,
name: r.get(2)?,
display_name: r.get(3)?,
kind: match r.get::<_, String>(4)?.as_str() {
"room" => UserType::Room,
"resource" => UserType::Resource,
_ => UserType::Individual,
@@ -2342,6 +2375,10 @@ mod tests {
assert!(u.active, "v2 migration must default active to true");
assert!(u.is_admin);
assert_eq!(db.user_roots(u.id).await.unwrap().len(), 1);
// Accounts from before CalDAV get their principal.
let p = db.pim_principal("legacy").await.unwrap().unwrap();
assert_eq!(p.user_id, Some(u.id));
assert_eq!(db.principal_of(u.id).await.unwrap(), p.id);
// Migrations are idempotent.
let db2 = Db::open(&path).await.unwrap();
assert_eq!(db2.user_count().await.unwrap(), 1);
@@ -2354,6 +2391,56 @@ mod tests {
);
}
#[tokio::test]
async fn rooms_are_principals_not_accounts() {
let (db, admin) = db_with_admin().await;
let bob = db.create_user("bob", "hash", false, &[]).await.unwrap();
let room = db
.create_room("board", "Board", UserType::Room)
.await
.unwrap()
.unwrap();
assert_eq!(room.user_id, None);
// No account query returns the room.
assert_eq!(db.user_count().await.unwrap(), 2);
assert!(db.find_user_by_name("board").await.unwrap().is_none());
assert!(db.verify_password("board", "").await.unwrap().is_none());
let names: Vec<_> = db.all_users_with_roots().await.unwrap();
assert!(names.iter().all(|(u, _)| u.name != "board"));
// One name space, without case.
assert!(db.name_taken("BOARD").await.unwrap());
assert!(db.create_user("Board", "hash", false, &[]).await.is_err());
assert!(
db.create_room("BOB", "Bob", UserType::Resource)
.await
.unwrap()
.is_none()
);
// Deleting an account takes its principal, collections and loans.
let bob_p = db.principal_of(bob.id).await.unwrap();
db.pim_ensure_defaults(bob_p).await.unwrap();
let admin_p = db.principal_of(admin.id).await.unwrap();
db.pim_ensure_defaults(admin_p).await.unwrap();
let bobs = db.pim_collections(bob_p, PimKind::Calendar).await.unwrap();
let admins = db
.pim_collections(admin_p, PimKind::Calendar)
.await
.unwrap();
db.pim_set_share(admins[0].id, bob.id, Mode::Ro)
.await
.unwrap();
assert!(db.delete_user(bob.id).await.unwrap());
assert!(db.pim_principal("bob").await.unwrap().is_none());
assert!(!db.name_taken("bob").await.unwrap());
assert!(db.pim_collection_by_id(bobs[0].id).await.unwrap().is_none());
assert!(db.pim_shares(admins[0].id).await.unwrap().is_empty());
// The room outlives it.
assert!(db.pim_principal("board").await.unwrap().is_some());
}
#[tokio::test]
async fn admin_user_and_passwords() {
let (db, admin) = db_with_admin().await;
Mserver/tests/api_pim.rs
@@ -1401,8 +1401,14 @@ async fn rooms_and_resources() {
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = admin.delete(&format!("/api/admin/users/{id}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = req(&env, "PROPFIND", "/pim/", &basic("board", ""), &[], "").await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let r = Client::new(env.app.clone())
.post_json("/api/auth/login", &json!({"name": "board", "password": ""}))
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let p = "/pim/principals/board/";
let body = propfind_body(&[