Multi-tenant: albums owned per photographer

- albums.owner_id (migration 0003, backfilled to the original user)
- owned::{album,photo,share} are the only admin data-access paths; another
  tenant's resources are indistinguishable from nonexistent (404)
- every admin handler threaded through ownership; each ALLOWED_EMAILS entry
  is now its own isolated workspace
- tenant-isolation integration test matrix (tests/tenancy.rs, env-gated on
  TEST_DATABASE_URL) driving the real router
This commit is contained in:
2026-07-17 14:52:23 +02:00
parent 6259ca84d8
commit 40f7f2fb5e
12 changed files with 357 additions and 36 deletions
Generated
+1
View File
@@ -2574,6 +2574,7 @@ dependencies = [
"time",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
+1
View File
@@ -45,6 +45,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
cookie = { version = "0.18", features = ["signed"] }
tower = { version = "0.5", features = ["util"] }
[profile.release]
lto = "thin"
+12 -4
View File
@@ -30,10 +30,13 @@ collect ratings and tags, and let clients download originals.
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
stays fully private; all image traffic is streamed through the API with
auth checks (no bucket CORS or public access needed).
- **Auth**: photographer signs in via any OIDC provider (authorization-code
- **Auth**: photographers sign in via any OIDC provider (authorization-code
flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions
are re-checked against the allowlist on every request, so removing an email
revokes access immediately. Clients use unguessable share tokens, optionally
revokes access immediately. **Multi-tenant**: each allowed email is its own
workspace — albums, photos, and share links are owned per photographer and
invisible to the others (enforced via ownership-scoped data access and
covered by the tenant-isolation test matrix). Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
@@ -55,6 +58,13 @@ cargo run --bin worker # job worker (separate terminal, same env)
cd frontend && npm install && npm run dev # UI on :5173, proxies /api
```
Tests (the tenant-isolation matrix needs a disposable database):
```sh
createdb photos_test # or: docker compose exec postgres createdb -U photos photos_test
TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
```
Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback`
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
@@ -158,8 +168,6 @@ Notes:
- Full RAW develop fallback for files whose embedded preview is tiny
(exceedingly rare on modern cameras; `darktable-cli` in the worker image
would cover it).
- Multiple photographer accounts with separate libraries (any allowed email
sees everything).
- No S3 orphan sweeper: a crash in the narrow window between an upload's S3
put and its DB commit can leave an unreferenced original in the bucket
(never data loss — just unclaimed storage).
+10
View File
@@ -0,0 +1,10 @@
-- Albums gain an owner: the tenancy root. Photos, shares, ratings and tags
-- all hang off albums, so this single column scopes everything.
alter table albums add column owner_id uuid references users(id);
-- Existing albums belong to the instance's original photographer.
update albums set owner_id = (select id from users order by created_at limit 1);
alter table albums alter column owner_id set not null;
create index albums_owner_idx on albums (owner_id);
+1
View File
@@ -4,6 +4,7 @@ pub mod error;
pub mod imaging;
pub mod jobs;
pub mod models;
pub mod owned;
pub mod routes;
pub mod s3;
pub mod state;
+2
View File
@@ -96,6 +96,8 @@ impl JobStatus {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Album {
pub id: Uuid,
#[serde(skip_serializing)]
pub owner_id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
+46
View File
@@ -0,0 +1,46 @@
//! Tenant-scoped data access. These are the ONLY functions admin handlers may
//! use to fetch albums, photos, or shares — every one joins ownership, so a
//! handler cannot accidentally reach across tenants. Another user's resource
//! is indistinguishable from a nonexistent one (404).
use uuid::Uuid;
use crate::error::ApiError;
use crate::models::{Album, Photo, Share};
use crate::state::AppState;
pub async fn album(state: &AppState, album_id: Uuid, owner: Uuid) -> Result<Album, ApiError> {
let album: Option<Album> =
sqlx::query_as("select * from albums where id = $1 and owner_id = $2")
.bind(album_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
album.ok_or_else(ApiError::not_found)
}
pub async fn photo(state: &AppState, photo_id: Uuid, owner: Uuid) -> Result<Photo, ApiError> {
let photo: Option<Photo> = sqlx::query_as(
"select p.* from photos p
join albums a on a.id = p.album_id
where p.id = $1 and a.owner_id = $2",
)
.bind(photo_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
photo.ok_or_else(ApiError::not_found)
}
pub async fn share(state: &AppState, share_id: Uuid, owner: Uuid) -> Result<Share, ApiError> {
let share: Option<Share> = sqlx::query_as(
"select s.* from shares s
join albums a on a.id = s.album_id
where s.id = $1 and a.owner_id = $2",
)
.bind(share_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
share.ok_or_else(ApiError::not_found)
}
+26 -15
View File
@@ -6,8 +6,10 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -23,6 +25,7 @@ pub struct AlbumListItem {
pub async fn list(
State(state): State<AppState>,
user: AuthUser,
) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as(
"select a.id, a.name, a.description, a.created_at,
@@ -35,9 +38,11 @@ pub async fn list(
order by coalesce(p.taken_at, p.created_at), p.filename
limit 1
) c on true
where a.owner_id = $2
order by a.created_at desc",
)
.bind(PhotoStatus::Ready.as_str())
.bind(user.id)
.fetch_all(&state.db)
.await?;
Ok(Json(albums))
@@ -52,18 +57,21 @@ pub struct CreateAlbum {
pub async fn create(
State(state): State<AppState>,
user: AuthUser,
Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("album name is required"));
}
let album: Album =
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
let album: Album = sqlx::query_as(
"insert into albums (owner_id, name, description) values ($1, $2, $3) returning *",
)
.bind(user.id)
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
@@ -94,12 +102,10 @@ pub struct AlbumDetail {
pub async fn get_one(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> {
let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let album = owned::album(&state, album_id, user.id).await?;
let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename",
@@ -159,6 +165,7 @@ pub struct UpdateAlbum {
pub async fn update(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> {
@@ -170,12 +177,13 @@ pub async fn update(
let album: Album = sqlx::query_as(
"update albums
set name = coalesce($2, name), description = coalesce($3, description)
where id = $1
where id = $1 and owner_id = $4
returning *",
)
.bind(album_id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim))
.bind(user.id)
.fetch_one(&state.db)
.await?;
Ok(Json(album))
@@ -183,16 +191,19 @@ pub async fn update(
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
// Lock the album row: concurrent uploads block on their FK check against
// it, then fail once it's gone and clean up their own S3 objects — so no
// photo can slip in between the cleanup enqueue and the cascade delete.
let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
.bind(album_id)
.fetch_optional(&mut *tx)
.await?;
let locked: Option<(Uuid,)> =
sqlx::query_as("select id from albums where id = $1 and owner_id = $2 for update")
.bind(album_id)
.bind(user.id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
return Err(ApiError::not_found());
}
+10 -7
View File
@@ -9,9 +9,11 @@ use sha2::Digest;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::jobs;
use crate::models::{JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::s3;
use crate::state::AppState;
@@ -41,18 +43,13 @@ fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
pub async fn upload(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Query(query): Query<UploadQuery>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Json<Photo>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
owned::album(&state, album_id, user.id).await?;
let filename = sanitize_filename(&query.filename)?;
let content_type = headers
@@ -178,8 +175,10 @@ pub async fn upload(
/// content already exists in the album.
pub async fn by_hash(
State(state): State<AppState>,
user: AuthUser,
Path((album_id, sha256)): Path<(Uuid, String)>,
) -> ApiResult<Json<Photo>> {
owned::album(&state, album_id, user.id).await?;
let photo: Option<Photo> =
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
.bind(album_id)
@@ -191,8 +190,10 @@ pub async fn by_hash(
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
@@ -213,8 +214,10 @@ pub async fn delete(
pub async fn reprocess(
State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?;
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id)
+10 -8
View File
@@ -7,8 +7,9 @@ use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::random_token;
use crate::auth::{random_token, AuthUser};
use crate::error::{ApiError, ApiResult};
use crate::owned;
use crate::state::AppState;
#[derive(sqlx::FromRow)]
@@ -48,8 +49,10 @@ const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_do
pub async fn list(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
owned::album(&state, album_id, user.id).await?;
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
))
@@ -75,16 +78,11 @@ fn default_true() -> bool {
pub async fn create(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Json(body): Json<CreateShare>,
) -> ApiResult<Json<serde_json::Value>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
owned::album(&state, album_id, user.id).await?;
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
@@ -131,8 +129,10 @@ pub async fn create(
/// their way into the 15-minute lock).
pub async fn reset_lock(
State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let updated =
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share_id)
@@ -146,8 +146,10 @@ pub async fn reset_lock(
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let deleted = sqlx::query("delete from shares where id = $1")
.bind(share_id)
.execute(&state.db)
+3 -2
View File
@@ -81,12 +81,13 @@ async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError
pub async fn album_zip(
State(state): State<AppState>,
user: crate::auth::AuthUser,
Path(album_id): Path<Uuid>,
Form(request): Form<ZipRequest>,
) -> ApiResult<Response> {
let name = album_name(&state, album_id).await?;
let album = crate::owned::album(&state, album_id, user.id).await?;
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &name)
stream_zip(state, photos, &album.name)
}
pub async fn share_zip(
+235
View File
@@ -0,0 +1,235 @@
//! Tenant-isolation matrix: every admin endpoint must treat another user's
//! resources as nonexistent (404), and unauthenticated requests as 401.
//!
//! Needs a disposable Postgres database:
//! TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
//! Skips silently when TEST_DATABASE_URL is unset. S3 is never contacted —
//! the matrix stops at the ownership checks by construction.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use cookie::{Cookie, CookieJar, Key};
use sha2::{Digest, Sha512};
use tower::ServiceExt;
use uuid::Uuid;
use photos::config::Config;
use photos::state::AppState;
const SECRET: &str = "test-secret-test-secret-test-secret-1234";
fn test_config(database_url: String) -> Config {
Config {
database_url,
bind_addr: "127.0.0.1:0".into(),
public_url: "http://localhost:8080".into(),
session_secret: SECRET.into(),
s3_bucket: "photos".into(),
// Unroutable on purpose: no test may reach S3.
s3_endpoint: Some("http://127.0.0.1:9".into()),
s3_region: "us-east-1".into(),
s3_access_key: "test".into(),
s3_secret_key: "test".into(),
s3_force_path_style: true,
oidc_issuer: "https://auth.invalid".into(),
oidc_client_id: "x".into(),
oidc_client_secret: "x".into(),
allowed_emails: vec!["a@test".into(), "b@test".into()],
static_dir: "frontend/dist".into(),
worker_concurrency: 1,
dev_autologin_email: None,
}
}
fn session_for(user_id: Uuid, email: &str) -> String {
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
let exp = chrono::Utc::now().timestamp() + 3600;
let mut jar = CookieJar::new();
jar.signed_mut(&key).add(Cookie::new(
"photos_session",
format!("{user_id}|{exp}|{email}"),
));
format!("photos_session={}", jar.get("photos_session").unwrap().value())
}
async fn request(
router: &axum::Router,
method: &str,
path: &str,
cookie: Option<&str>,
json: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let body = match json {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(value.to_string())
}
// Zip endpoints take a form; everything else ignores the body.
None if path.ends_with("/zip") => {
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
Body::from("ids=")
}
None => Body::empty(),
};
let response = router
.clone()
.oneshot(builder.body(body).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.unwrap();
let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, value)
}
async fn seed_user(state: &AppState, email: &str) -> Uuid {
let (id,): (Uuid,) = sqlx::query_as(
"insert into users (oidc_subject, email) values ($1, $2) returning id",
)
.bind(format!("test:{email}"))
.bind(email)
.fetch_one(&state.db)
.await
.unwrap();
id
}
#[tokio::test]
async fn tenant_isolation_matrix() {
let Ok(database_url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("TEST_DATABASE_URL not set — skipping tenancy matrix");
return;
};
let state = AppState::new(test_config(database_url)).await.unwrap();
sqlx::query("truncate users, albums, photos, shares, ratings, tags, jobs cascade")
.execute(&state.db)
.await
.unwrap();
let alice = seed_user(&state, "a@test").await;
let bob = seed_user(&state, "b@test").await;
let cookie_a = session_for(alice, "a@test");
let cookie_b = session_for(bob, "b@test");
let router = photos::routes::router(&state).with_state(state.clone());
// Alice creates an album through the real API.
let (status, album) = request(
&router,
"POST",
"/api/albums",
Some(&cookie_a),
Some(serde_json::json!({ "name": "Alice's Wedding" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let album_id = album["id"].as_str().unwrap().to_string();
// Seed a photo (kept non-ready so no code path reaches S3) and a share.
let photo_id = Uuid::new_v4();
sqlx::query(
"insert into photos (id, album_id, filename, content_type, size_bytes, sha256)
values ($1, $2::uuid, 'a.jpg', 'image/jpeg', 3, 'hash-a')",
)
.bind(photo_id)
.bind(&album_id)
.execute(&state.db)
.await
.unwrap();
let share_id = Uuid::new_v4();
sqlx::query("insert into shares (id, album_id, token) values ($1, $2::uuid, 'tenanttesttoken123456789')")
.bind(share_id)
.bind(&album_id)
.execute(&state.db)
.await
.unwrap();
// ---- Bob vs Alice's resources: everything must be a 404 (or absent). ----
let bob_hits: &[(&str, String, Option<serde_json::Value>)] = &[
("GET", format!("/api/albums/{album_id}"), None),
(
"PATCH",
format!("/api/albums/{album_id}"),
Some(serde_json::json!({ "name": "stolen" })),
),
("DELETE", format!("/api/albums/{album_id}"), None),
("POST", format!("/api/albums/{album_id}/photos?filename=x.jpg"), None),
("GET", format!("/api/albums/{album_id}/shares"), None),
(
"POST",
format!("/api/albums/{album_id}/shares"),
Some(serde_json::json!({ "label": "x" })),
),
("POST", format!("/api/albums/{album_id}/zip"), None),
("GET", format!("/api/albums/{album_id}/photos/by-hash/hash-a"), None),
("DELETE", format!("/api/photos/{photo_id}"), None),
("POST", format!("/api/photos/{photo_id}/reprocess"), None),
("DELETE", format!("/api/shares/{share_id}"), None),
("POST", format!("/api/shares/{share_id}/reset-lock"), None),
];
for (method, path, body) in bob_hits {
let (status, _) = request(&router, method, path, Some(&cookie_b), body.clone()).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"cross-tenant {method} {path} must 404"
);
}
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_b), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums");
// ---- Alice keeps full access to her own resources. ----
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 1);
let (status, _) = request(
&router,
"GET",
&format!("/api/albums/{album_id}"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&router,
"GET",
&format!("/api/albums/{album_id}/photos/by-hash/hash-a"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
// Ownership check runs before the body is read: empty upload = 400, not 404.
let (status, _) = request(
&router,
"POST",
&format!("/api/albums/{album_id}/photos?filename=x.jpg"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// No ready photos yet: zip is a 400 for the owner, never an S3 call.
let (status, _) = request(
&router,
"POST",
&format!("/api/albums/{album_id}/zip"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// ---- No session at all: 401 on the admin surface. ----
for path in ["/api/albums", "/api/me"] {
let (status, _) = request(&router, "GET", path, None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{path} without session");
}
}