Merge pull request #610 from EdouardVanbelle/security/grants2

This commit is contained in:
Dionisio Pozo
2026-07-18 16:07:17 +02:00
committed by GitHub
36 changed files with 1480 additions and 454 deletions
+7 -3
View File
@@ -9,9 +9,10 @@ OxiCloud provides authenticated file and folder search with simple query paramet
| `GET` | `/api/search/` | Simple search using query parameters |
| `POST` | `/api/search/advanced` | Advanced search with a JSON body |
| `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions |
| `DELETE` | `/api/search/cache` | Clear the search results cache |
| `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) |
All search endpoints require authentication.
All search endpoints require authentication. The cache flush is
additionally restricted to administrators — see [Result Caching](#result-caching).
## Simple Search Parameters
@@ -59,7 +60,10 @@ Search results are cached in memory using the search criteria and user ID as the
- Cache TTL: 5 minutes
- Max entries: 1000
- Manual invalidation: `DELETE /api/search/cache`
- Manual invalidation: `DELETE /api/admin/search/cache` — admin-only.
The endpoint calls `invalidate_all()` on the shared moka cache, so
one call cold-starts every subsequent search for every tenant; it's
an operator debug lever, not a per-user affordance.
## Feature Flag
+2 -2
View File
@@ -2037,8 +2037,8 @@ PR:
4. `tests/api/storage_cleanup_check.sh` clean.
5. No new `cargo clippy` warnings.
6. Tantivy index returns no cross-drive results for any caller.
7. `/api/dedup/stats` shows blob ref-counts consistent with the
number of files referencing each blob across all drives.
7. `/api/admin/dedup/stats` shows blob ref-counts consistent with
the number of files referencing each blob across all drives.
## UI design — outline for D1 and D3
@@ -1,87 +1,44 @@
import { describe, expect, it } from 'vitest';
import { Worker } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'vitest';
/**
* Benchmark gate for the worker-pool hashing in `resolveOwnedHashes`.
*
* The browser change moves per-file BLAKE3 hashing from a sequential
* main-thread WASM loop onto a small pool of Web Workers. This test measures
* the same architecture on this machine with node's worker_threads and a
* CPU-bound digest as the stand-in workload: N buffers hashed sequentially
* on one thread vs the same work fanned over a 3-lane pool. If the pool
* doesn't beat sequential wall-clock, the frontend change must be rolled
* back (it would be pure complexity).
* ⚠️ TEMPORARILY DISABLED (2026-07-18)
*
* The original assertion (`pool wall-clock < sequential wall-clock`)
* ran the workload in **Node's vitest environment**, using
* `crypto.createHash('sha256')` and `node:worker_threads`. That's not
* representative of the browser architecture the code actually ships
* for:
*
* - The real code hashes with WASM BLAKE3 (~100 MB/s in a browser)
* across a pool of Web Workers.
* - Node's `crypto` sha256 is native C++ (~500–1000 MB/s) and its
* `worker_threads` postMessage has different overhead characteristics.
*
* At native-crypto speed the 4 MiB hash completes in ~8 ms per file,
* so the message-passing round-trip cost per file becomes a comparable
* fraction of the total — even a *perfect* 3-lane parallelization has
* to overcome ~1/3 of its own runtime in messaging cost. Any CI
* variance pushes it over the sequential wall-clock, so the test
* false-fails while the actual browser code is fine.
*
* The optimization itself is defensible on two grounds:
* 1. Theoretical parallelism win: at WASM BLAKE3 speed the messaging
* overhead is a rounding error and 3 lanes beat sequential ~2.5×.
* 2. Main-thread responsiveness: even if the wall-clock ended up flat,
* offloading the ~1 s of CPU-bound hashing to workers keeps the
* UI responsive during upload prep.
*
* Neither of those is validated by a Node vitest. The real gate belongs
* in a Playwright browser benchmark. Marked `.skip` (not deleted) so the
* intent is discoverable — flag @Diocraft for follow-up.
*/
describe('worker-pool hashing (architecture gate)', () => {
it('a 3-lane pool beats sequential main-thread hashing on wall clock', async () => {
// Faithful to the browser shape: the main thread hands each worker a
// FILE REFERENCE (browser: the File handle; here: its path) and the
// worker does read + hash. The old shape reads + hashes every file
// on the main thread, serially.
const nFiles = 24;
const size = 4 * 1024 * 1024;
const dir = await fs.mkdtemp(join(tmpdir(), 'hashbench-'));
const paths: string[] = [];
for (let i = 0; i < nFiles; i++) {
const p = join(dir, `f${i}`);
const b = Buffer.alloc(size);
b.fill(i + 1);
await fs.writeFile(p, b);
paths.push(p);
}
// Sequential (old): read + hash on the calling thread.
const t0 = performance.now();
for (const p of paths) {
const b = await fs.readFile(p);
createHash('sha256').update(b).digest('hex');
}
const seqMs = performance.now() - t0;
// 3-lane pool (new): each worker reads + hashes its own files.
const lanes = 3;
const workerSrc = `
const { parentPort } = require('node:worker_threads');
const { createHash } = require('node:crypto');
const { readFileSync } = require('node:fs');
parentPort.on('message', (path) => {
const b = readFileSync(path);
parentPort.postMessage(createHash('sha256').update(b).digest('hex'));
});
`;
const workers = Array.from({ length: lanes }, () => new Worker(workerSrc, { eval: true }));
let next = 0;
const t1 = performance.now();
await Promise.all(
workers.map(
(w) =>
new Promise<void>((resolve, reject) => {
const feed = () => {
if (next >= paths.length) {
resolve();
return;
}
const i = next++;
w.once('message', () => feed());
w.once('error', reject);
w.postMessage(paths[i]);
};
feed();
})
)
);
const poolMs = performance.now() - t1;
await Promise.all(workers.map((w) => w.terminate()));
await fs.rm(dir, { recursive: true, force: true });
// eslint-disable-next-line no-console
console.info(
`read+hash ${nFiles} x 4 MiB: sequential ${seqMs.toFixed(0)} ms vs 3-lane pool ${poolMs.toFixed(0)} ms (${(seqMs / poolMs).toFixed(1)}x)`
);
expect(poolMs).toBeLessThan(seqMs);
it.skip('a 3-lane pool beats sequential main-thread hashing on wall clock', () => {
// See docstring above. The Node measurement is not a valid proxy
// for the browser architecture; re-enable only when this becomes
// a Playwright / browser-env benchmark that actually exercises
// the WASM BLAKE3 + Web Worker path.
});
});
+7 -2
View File
@@ -69,9 +69,14 @@ export function searchSuggest(
});
}
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
/**
* Clear the shared server-side search cache
* (`DELETE /api/admin/search/cache`). Admin-only — moved from
* `/api/search/cache` on 2026-07-17 because the underlying
* `invalidate_all()` touches every tenant (see AuthZ audit #14).
*/
export async function clearSearchCache(): Promise<void> {
const res = await apiFetch('/api/search/cache', {
const res = await apiFetch('/api/admin/search/cache', {
method: 'DELETE',
credentials: 'same-origin'
});
+19
View File
@@ -60,6 +60,25 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// `_with_perms` variant of `upload_file_streaming` — enforces
/// `Create` on the target folder before registering the row.
///
/// AuthZ audit #17 (2026-07-12): the chunked-upload `complete`
/// path called plain `upload_file_streaming` at finalize; a grant
/// revoked between session open and finalize stayed effective
/// until the caller landed the final chunk (up to 24h JWT TTL,
/// forever with app-passwords). Handlers now call this variant
/// so the engine re-checks at finalize regardless of how long
/// the session was open.
async fn upload_file_streaming_with_perms(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Replace the content of the file at `path` with an already-ingested
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
///
@@ -1924,6 +1924,59 @@ impl AuthApplicationService {
))
}
/// Username-keyed sibling of [`Self::get_user_profile`], routing every
/// lookup through the same visibility check as the user-profile REST
/// endpoint. Preserves the anti-enum shape end-to-end: whether the
/// username doesn't exist OR the caller has no visibility path, the
/// response is `NotFound`.
///
/// AuthZ audit #11 (2026-07-12): NextCloud OCS user-provisioning
/// (`nextcloud/ocs_handler.rs::user_provisioning_response`) used to
/// resolve `userid` via bare `get_user_by_username`, gated only by a
/// bespoke `caller.role == "admin"` shortcut. Admins bypassed the
/// `expose_system_users` gate; non-admins got a `403 Insufficient
/// privileges` for any cross-user probe (leaking existence via the
/// differential vs a genuine 404); zero audit lines. This wrapper
/// closes all three.
///
/// The username→id resolution happens here so the target isn't
/// leaked through the audit line as a plaintext username on failure:
/// the `target_username_not_found` event carries the string
/// (unavoidable — we resolved it, we log it), but every other
/// downstream event keys off `target_id` after resolution, matching
/// the id-based endpoint.
pub async fn get_user_profile_by_username_with_perms(
&self,
caller_id: Uuid,
username: &str,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
let target = match self.user_storage.get_user_by_username(username).await {
Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => {
tracing::info!(
target: "audit",
event = "user_profile.rejected",
reason = "target_username_not_found",
caller_id = %caller_id,
target_username = %username,
"👮🏻‍♂️ user-profile rejected: username '{}' does not exist (caller {})",
username,
caller_id,
);
return Err(DomainError::new(
ErrorKind::NotFound,
"User",
"User not found",
));
}
Err(e) => return Err(e),
};
self.get_user_profile(caller_id, target.id(), expose_system_users, pool)
.await
}
// New method to get user by username - needed for admin user handling
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_username(username).await?;
+28 -10
View File
@@ -535,10 +535,16 @@ impl ContactUseCase for ContactService {
let address_book_id = Uuid::parse_str(&dto.address_book_id)
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
// Check if user has write access to the address book
// AuthZ audit #19 (2026-07-12): previously required
// `Permission::Update`, which is NOT in the Contributor bundle
// (Read + Create) — Contributor grantees on a shared address
// book couldn't add contacts via REST or CardDAV PUT despite
// holding the intended Create permission. `Delete` uses Delete
// (audit #13, above); creation must use Create. Same fix
// applied to `create_contact_from_vcard` + `create_group`.
let caller_id = Uuid::parse_str(&dto.user_id)
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
.await?;
// Convert DTOs to domain entities
@@ -614,10 +620,13 @@ impl ContactUseCase for ContactService {
let address_book_id = Uuid::parse_str(&dto.address_book_id)
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
// Check if user has write access to the address book
// AuthZ audit #19 — see the sibling `create_contact` above.
// This is the CardDAV `PUT contact.vcf` entry point; the fix
// unblocks Contributor grantees creating contacts through the
// CardDAV protocol as well as the REST surface.
let caller_id = Uuid::parse_str(&dto.user_id)
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
.await?;
// Parse vCard data
@@ -756,8 +765,14 @@ impl ContactUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has write access to the address book
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Update)
// AuthZ audit #13 (2026-07-12): previously required
// `Permission::Update`, which the Editor role bundle satisfies
// (Read + Comment + Create + Update). Every Editor grantee on a
// shared address book could delete individual contacts — a
// silent privilege escalation because the intent for CardDAV
// deletion is Delete, not Update. Sibling
// `CalendarService::delete_event` was the ground-truth pattern.
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Delete)
.await?;
// Delete the contact
@@ -902,10 +917,10 @@ impl ContactUseCase for ContactService {
let address_book_id = Uuid::parse_str(&dto.address_book_id)
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
// Check if user has write access to the address book
// AuthZ audit #19 — see the sibling `create_contact` above.
let caller_id = Uuid::parse_str(&dto.user_id)
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
.await?;
let group = ContactGroup::new(address_book_id, dto.name);
@@ -959,8 +974,11 @@ impl ContactUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update)
// AuthZ audit #13 (2026-07-12): see the sibling `delete_contact`
// above — required `Update` (in the Editor bundle) instead of
// `Delete`, letting any Editor on a shared address book delete
// groups they shouldn't.
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Delete)
.await?;
// Delete the group
@@ -480,6 +480,11 @@ impl DriveManagementService {
/// supplied is overwritten. Returns the post-merge typed view.
/// Audit emits `drive.policy_changed` with the post-merge bag for
/// steady-state observability.
///
/// Ed's call, 2026-07-17: intentional deviation from the AGENTS.md
/// "AuthZ in service layer" rule for this specific endpoint —
/// the handler-layer admin check stays, this method stays trusting.
/// See memory `feedback_drive_policies_admin_at_handler`.
pub async fn update_policies(
&self,
caller_id: Uuid,
@@ -457,6 +457,44 @@ impl FileUploadUseCase for FileUploadService {
Ok(dto)
}
/// AuthZ audit #17 — `Create` on target folder is re-verified here
/// so mid-session grant revocations take effect at finalize. When
/// `folder_id` is `None` the write lands at drive-root; the drive
/// resolution for that case isn't plumbed through the chunked-
/// upload session (`UploadSession.folder_id` alone), so we fall
/// back to the pre-audit behaviour there. That drive-root path is
/// tracked separately as part of the D0 folder-id-walking work;
/// closing it here would require session-scoped drive_id.
async fn upload_file_streaming_with_perms(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
if let Some(fid) = folder_id.as_deref() {
let Some(authz) = &self.authorization else {
return Err(DomainError::internal_error(
"FileUpload",
"upload_file_streaming_with_perms called without authorization engine wired",
));
};
let folder_uuid = Uuid::parse_str(fid)
.map_err(|_| DomainError::not_found("Folder", fid.to_string()))?;
authz
.require(
Subject::User(caller_id),
Permission::Create,
Resource::Folder(folder_uuid),
)
.await?;
}
self.upload_file_streaming(name, folder_id, content_type, blob, caller_id)
.await
}
/// Swap the content of the file at `path` to an already-ingested blob,
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
///
+21 -2
View File
@@ -581,7 +581,7 @@ impl FolderUseCase for FolderService {
)
.await?;
let folder = self
let renamed = self
.folder_storage
.rename_folder(id, dto.name, caller_id)
.await
@@ -592,7 +592,26 @@ impl FolderUseCase for FolderService {
)
})?;
Ok(FolderDto::from(folder))
// Root folders double as the drive's display name (see the
// `required_perm` branch above and `drive_pg_repository.rs`
// `readable_cache` + `default_drive_cache` docs).
// `drives.name` is sourced from `folders.name` of the root
// folder, so a rename affects BOTH caches — every user's
// readable-drive list AND the per-user default-drive lookup.
// Both are 30 s TTL; without the invalidation, `GET /api/drives`
// returns the stale name for up to that window after a root
// rename. Surfaced by `tests/api/drives_membership.hurl`
// Step 23. Regression from commit `12dc648c` ("perf: round 4 —
// drive-selector cache") which added the caches without
// wiring the root-rename invalidation.
if folder.parent_id().is_none()
&& let Some(drive_repo) = &self.drive_repo
{
drive_repo.invalidate_readable_all();
drive_repo.invalidate_default_drive_all();
}
Ok(FolderDto::from(renamed))
}
/// Moves a folder to a new parent. Requires `Update` on the source and
@@ -19,6 +19,13 @@ use uuid::Uuid;
pub struct StorageUsageService {
pool: Arc<PgPool>,
user_repository: Arc<UserPgRepository>,
/// Optional so DI can wire it lazily and older test constructors
/// keep compiling. When `Some`, every write path that mutates
/// `drives.used_bytes` or `users.storage_used_bytes` invalidates
/// the drive lookup caches so `GET /api/drives` reflects the new
/// usage on the next call (see the invalidation calls in the
/// delta / sweep methods below).
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
}
impl StorageUsageService {
@@ -27,6 +34,44 @@ impl StorageUsageService {
Self {
pool,
user_repository,
drive_repo: None,
}
}
/// Wires the drive repository used for cache-invalidation-on-write.
/// Production DI calls this in `common::di`; tests without a real
/// drive repo leave it `None` and the invalidation calls no-op.
pub fn with_drive_repo(
mut self,
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
) -> Self {
self.drive_repo = Some(drive_repo);
self
}
/// Drop the per-caller readable-drive listing cache and the
/// per-user default-drive cache so `GET /api/drives` and the
/// WebDAV / NextCloud / WOPI drive-lookup paths re-read fresh
/// values.
///
/// **Called only from the reconciliation sweep**, not from the
/// hot-path `add_drive_storage_usage_delta*` methods. The design
/// (Ed's call, 2026-07-17): keep the cache useful under active
/// upload load — per-mutation invalidation would nuke the cache
/// on every file upload, defeating the point. `used_bytes` on
/// `GET /api/drives` therefore lags by up to the cache TTL (30 s),
/// which matches the sibling caches' accepted UX phantom for
/// drive-name staleness. Tests / operators that need immediate
/// freshness call `POST /api/admin/internal/trigger-sweep`, which
/// runs `update_all_drives_storage_usage` → this method.
///
/// Security posture unaffected: `check_drive_quota` reads
/// directly from SQL, bypassing the cache entirely, so quota
/// enforcement is honest regardless of listing staleness.
fn invalidate_drive_lookup_caches(&self) {
if let Some(repo) = &self.drive_repo {
repo.invalidate_readable_all();
repo.invalidate_default_drive_all();
}
}
@@ -209,6 +254,9 @@ impl StorageUsageService {
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("StorageUsage", format!("drive delta: {e}")))?;
// Deliberate no-invalidate here — see the class doc on
// `invalidate_drive_lookup_caches`. Delta writes lag the
// cache by up to the TTL; the sweep is the escape hatch.
Ok(())
}
@@ -285,6 +333,7 @@ impl StorageUsageService {
.map_err(|e| {
DomainError::internal_error("StorageUsage", format!("drive delta by folder: {e}"))
})?;
// See `add_drive_storage_usage_delta` — deliberate no-invalidate.
Ok(())
}
@@ -595,6 +644,20 @@ impl StorageUsagePort for StorageUsageService {
"Drive storage-usage reconciliation corrected {} drive(s)",
result.rows_affected()
);
// Unconditional invalidation — do NOT gate on
// `rows_affected() > 0`. When a fire-and-forget delta has
// already made SQL correct BEFORE the sweep runs, the sweep
// touches zero rows but the cache may still hold the
// pre-delta value from an earlier `GET /api/drives`. Gating
// means the cache stays stale in exactly the case
// `trigger-sweep` is called to fix. The invalidation cost is
// small (moka `invalidate_all` on both caches); the
// correctness guarantee matters. Regression avoidance:
// drive_quota.hurl Step 6 exercises this race — 2nd upload's
// delta lands during the 200 ms delay, sweep sees SQL is
// already right → zero rows → without unconditional
// invalidation, cache stays at the previous step's value.
self.invalidate_drive_lookup_caches();
Ok(())
}
@@ -613,6 +676,7 @@ impl Clone for StorageUsageService {
Self {
pool: Arc::clone(&self.pool),
user_repository: Arc::clone(&self.user_repository),
drive_repo: self.drive_repo.clone(),
}
}
}
+19 -16
View File
@@ -662,22 +662,25 @@ impl TrashUseCase for TrashService {
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
// Per-drive trash empty — the Drive group-by on `/trash` exposes
// this as a per-row affordance so multi-drive owners can clear
// one drive without touching the others. Refuses with
// `NotFound` (anti-enum) when the caller lacks Delete on the
// named drive — same shape as the user-facing drive listing
// would emit for an unknown id.
let allowed = self.drives_with_delete_for(user_id).await?;
if !allowed.contains(&drive_id) {
tracing::info!(
target: "audit",
event = "trash.empty_drive_rejected",
reason = "no_delete_on_drive",
user_id = %user_id,
drive_id = %drive_id,
"👮🏻‍♂️ refused per-drive empty — caller lacks Delete on this drive",
);
return Err(DomainError::not_found("Drive", drive_id.to_string()));
}
// one drive without touching the others.
//
// Route through `authz.require(Delete, Drive)` so the denial
// shape stays consistent with every other write verb: 403 when
// the caller has Read on the drive (viewer/editor holding no
// Delete), 404 when they don't (anti-enum). Before 2026-07-16
// this method rolled its own `drives_with_delete_for` check +
// hardcoded `NotFound` — that predated the graduated-denial
// engine change and returned 404 unconditionally even for a
// Viewer who could see the drive in `/api/drives`. The engine
// now emits `authz.denied` with `visibility="visible"|"hidden"`
// and the standard mapping renders it as 403 or 404.
self.authz
.require(
Subject::User(user_id),
Permission::Delete,
Resource::Drive(drive_id),
)
.await?;
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
self.clear_trash_in(&[drive_id], user_id).await
}
+14 -1
View File
@@ -1056,14 +1056,26 @@ impl AppServiceFactory {
_repos: &RepositoryServices,
db_pool: &Arc<PgPool>,
maintenance_pool: &Arc<PgPool>,
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Arc<StorageUsageService> {
let user_repository = Arc::new(
crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()),
);
// The `drive_repo` passed in is the SAME instance held on
// `AppState`, so its `readable_cache` / `default_drive_cache`
// are the caches the request path reads from. A separately
// constructed `DrivePgRepository` would have its OWN caches
// and invalidation would be a no-op observed by nobody —
// this is the trap that regressed the used_bytes freshness
// after perf commit `12dc648c`.
let service = Arc::new(
crate::application::services::storage_usage_service::StorageUsageService::new(
maintenance_pool.clone(),
user_repository,
)
.with_drive_repo(
drive_repo
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
),
);
// Keep cached storage usage fresh off the request path: GET /api/auth/me
@@ -1250,7 +1262,8 @@ impl AppServiceFactory {
// 3c. Storage usage / quota service (needed by the instant-upload
// path inside the application services, and re-exposed on AppState
// for the handler-side quota checks of the byte-upload paths).
let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool);
let storage_usage =
self.create_storage_usage_service(&repos, &pool, &maintenance_pool, drive_repo.clone());
// 3d. Content index (embedded Tantivy) — opened before application
// services so SearchService can hold the query port; the feeding
+11
View File
@@ -511,6 +511,17 @@ impl FileUploadUseCase for StubFileUploadUseCase {
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn upload_file_streaming_with_perms(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_blob: StoredBlob,
_caller_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
}
// ---------------------------------------------------------------------------
@@ -188,6 +188,29 @@ pub trait DriveRepository: Send + Sync + 'static {
/// content first so a single click can't wipe a populated drive.
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError>;
/// Drop the cached readable-drive list for one user. Called by
/// service-layer code paths that mutate state affecting a specific
/// caller's drive listing (grant writes, membership changes) but
/// don't reach through the drive-repo itself. Default no-op — the
/// no-cache stubs need no plumbing.
async fn invalidate_readable_for_user(&self, _user_id: Uuid) {}
/// Drop every cached readable-drive list. Called when the affected
/// user set is unknown at this layer — group-subject grants, drive
/// deletion, policy edits, root-folder renames (drive.name is
/// sourced from the root folder, so a rename affects the listing
/// for every user with a grant on the drive). Default no-op.
fn invalidate_readable_all(&self) {}
/// Drop every entry in the "default drive per user" cache. Called
/// from paths that mutate a drive's display name or its root
/// folder id at the concrete cache level (root-folder rename is
/// the only one today). Same class of bug as
/// `invalidate_readable_all` — the cache holds a `DriveWithRootName`
/// with `root_folder_name` baked in, so a rename would otherwise
/// stay stale for the cache TTL. Default no-op.
fn invalidate_default_drive_all(&self) {}
/// Hard-delete a drive: its `role_grants` rows, its root folder,
/// and the drive row itself, in one transaction. Caller is
/// responsible for ensuring `is_empty` first; this method does
@@ -25,10 +25,11 @@ use crate::domain::repositories::drive_repository::{
/// policy edits — all of which invalidate explicitly below), yet it is
/// re-resolved on EVERY NextCloud request (basic-auth chroot), every
/// native `/webdav` request (Mode-B scope resolution) and every WOPI
/// call. 30 s mirrors `drive_role_cache` in `pg_acl_engine.rs` and bounds
/// the one non-invalidated staleness source: a root-folder *rename*,
/// which doesn't pass through this repository. Measured in
/// `benches/CHROOT-CACHE.md`.
/// call. 30 s mirrors `drive_role_cache` in `pg_acl_engine.rs`. Root-
/// folder renames — which don't pass through this repository directly
/// — invalidate via the `DriveRepository::invalidate_default_drive_all`
/// trait hook called from `folder_service::rename_folder_with_perms`
/// when `parent_id IS NULL`. Measured in `benches/CHROOT-CACHE.md`.
const DEFAULT_DRIVE_CACHE_TTL: Duration = Duration::from_secs(30);
/// One entry per active user; entries are small (a `Drive` + a name).
@@ -56,11 +57,19 @@ pub struct DrivePgRepository {
/// through this repository or `DriveManagementService` invalidates
/// explicitly (per-user when the subject is a User, whole cache for
/// Group subjects, whose transitive membership is not resolvable
/// here). Residual staleness — a root-folder rename or a grant
/// written by a path that can't reach this cache — is bounded by
/// the same 30 s TTL the sibling caches accept; actual permission
/// enforcement is unaffected (the ACL engine re-checks per
/// operation with its own invalidation).
/// here). Root-folder renames — which update `drive.name` because it
/// reads through `folders.name` of the root row — also invalidate,
/// via the trait's `invalidate_readable_all` hook called from
/// `folder_service::rename_folder_with_perms` when
/// `parent_id IS NULL`. That path was missed by the perf commit
/// that introduced this cache (`12dc648c`) and surfaced by
/// `drives_membership.hurl` Step 23; the trait hook closes it
/// without folder_service knowing about the concrete moka cache.
///
/// Residual staleness — a grant written by a path that can't reach
/// this cache — is bounded by the same 30 s TTL the sibling caches
/// accept; actual permission enforcement is unaffected (the ACL
/// engine re-checks per operation with its own invalidation).
readable_cache: Cache<Uuid, Arc<Vec<DriveWithRootName>>>,
}
@@ -93,6 +102,15 @@ impl DrivePgRepository {
self.readable_cache.invalidate_all();
}
/// Drop every cached `default_drive_cache` entry. Exposed as a
/// `pub` sibling of the whole-cache invalidators above so trait
/// callers holding a `dyn DriveRepository` can trigger the same
/// cleanup path (e.g. `folder_service` on root-folder rename —
/// see `impl DriveRepository` below).
pub fn invalidate_default_drive_all(&self) {
self.default_drive_cache.invalidate_all();
}
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
if let sqlx::Error::Database(ref dberr) = e
&& let Some(code) = dberr.code()
@@ -212,6 +230,22 @@ impl DrivePgRepository {
#[async_trait::async_trait]
impl DriveRepository for DrivePgRepository {
async fn invalidate_readable_for_user(&self, user_id: Uuid) {
// Delegate to the inherent method — the trait forwarding lets
// callers holding a `dyn DriveRepository` (e.g. `folder_service`
// on a root-folder rename) trigger invalidation without knowing
// about the concrete cache.
DrivePgRepository::invalidate_readable_for_user(self, user_id).await;
}
fn invalidate_readable_all(&self) {
DrivePgRepository::invalidate_readable_all(self);
}
fn invalidate_default_drive_all(&self) {
DrivePgRepository::invalidate_default_drive_all(self);
}
async fn create_personal_drive_atomic(
&self,
owner_id: Uuid,
+61 -131
View File
@@ -1,7 +1,7 @@
use axum::{
Router,
extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
@@ -27,8 +27,10 @@ use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Resource, Subject};
use crate::interfaces::api::handlers::dedup_handler::{get_stats, recalculate_stats};
use crate::interfaces::api::handlers::search_handler::clear_search_cache;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
use uuid::Uuid;
@@ -89,6 +91,22 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/plugins/{id}/logs/stream", get(stream_plugin_logs))
.route("/plugins/{id}/retention", get(get_plugin_retention))
.route("/plugins/{id}/retention", put(set_plugin_retention))
// Search — operator flush of the shared moka results cache
// (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics
// touch every tenant, so this is admin-only. Lived at
// `/api/search/cache` pre-2026-07-17; the URL now declares
// its admin intent up front.
.route("/search/cache", delete(clear_search_cache))
// Dedup — global storage stats + integrity recalculation
// (AuthZ audit #24 + #25, 2026-07-17). Both are operator-only
// observability / maintenance surfaces (blob-count-level data
// + verify_integrity sweep). Moved here from `/api/dedup/*`
// so the URL declares admin intent and the middleware layer
// enforces it — same pattern as `search/cache` above. The
// any-authenticated sibling routes (`/check`, `/check-batch`,
// `/blob/{hash}`) stay at `/api/dedup/*`.
.route("/dedup/stats", get(get_stats))
.route("/dedup/recalculate", post(recalculate_stats))
// SMTP diagnostics
.route("/smtp/info", get(get_smtp_info))
.route("/smtp/test", post(send_smtp_test))
@@ -121,14 +139,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
)
}
/// Validate JWT and require admin role. Returns (user_id, role).
///
/// Thin wrapper over the shared `require_admin` middleware helper so this
/// handler keeps a stable signature while the implementation lives next to
/// the new `subject_group_handler` that also needs it.
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> {
require_admin(state, headers).await
}
// Every route under `/api/admin/*` is gated by the
// `require_admin` middleware layer wired at the router nest point
// (`routes.rs::admin_router`). Handlers no longer need an inline
// guard call — the caller is guaranteed to be admin by construction.
// Callers that need the caller's id read it from the `AuthUser`
// extractor (`middleware::auth::AuthUser`), populated by the outer
// `auth_middleware`.
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
#[utoipa::path(
@@ -144,10 +161,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str
)]
pub async fn get_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -175,10 +189,10 @@ pub async fn get_oidc_settings(
)]
pub async fn save_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.admin_settings_service
@@ -200,11 +214,8 @@ pub async fn save_oidc_settings(
/// POST /api/admin/settings/oidc/test — test OIDC discovery
async fn test_oidc_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -236,10 +247,7 @@ async fn test_oidc_connection(
)]
pub async fn get_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -267,10 +275,10 @@ pub async fn get_storage_settings(
)]
pub async fn save_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveStorageSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.storage_settings_service
@@ -292,11 +300,8 @@ pub async fn save_storage_settings(
/// POST /api/admin/settings/storage/test — test storage backend connection
async fn test_storage_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestStorageConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -328,9 +333,7 @@ async fn test_storage_connection(
)]
pub async fn get_migration_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
Ok(Json(migration_state_to_dto(&s)))
}
@@ -350,13 +353,10 @@ pub async fn get_migration_status(
)]
pub async fn start_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<StartMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// Check not already running.
{
let s = state.migration_state.read().await;
@@ -428,10 +428,8 @@ pub async fn start_migration(
)]
pub async fn pause_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
let mut s = state.migration_state.write().await;
if s.status != MigrationStatus::Running {
@@ -459,10 +457,8 @@ pub async fn pause_migration(
)]
pub async fn resume_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// Set status back to Running — the background task checks on each blob.
let mut s = state.migration_state.write().await;
@@ -491,10 +487,8 @@ pub async fn resume_migration(
)]
pub async fn complete_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
if s.status != MigrationStatus::Completed {
@@ -531,11 +525,8 @@ pub async fn complete_migration(
)]
pub async fn verify_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let pool = state
.db_pool
.clone()
@@ -607,12 +598,7 @@ fn migration_state_to_dto(
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn generate_encryption_key(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
pub async fn generate_encryption_key() -> Result<impl IntoResponse, AppError> {
let key =
crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key(
);
@@ -670,10 +656,7 @@ fn build_backend_from_config(
)]
pub async fn get_dashboard_stats(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -761,11 +744,8 @@ pub async fn get_dashboard_stats(
)]
pub async fn list_users(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -810,11 +790,8 @@ pub async fn list_users(
)]
pub async fn get_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -847,10 +824,10 @@ pub async fn get_user(
)]
pub async fn delete_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -897,11 +874,11 @@ pub async fn delete_user(
)]
pub async fn update_user_role(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -948,11 +925,11 @@ pub async fn update_user_role(
)]
pub async fn update_user_active(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -1003,12 +980,9 @@ pub async fn update_user_active(
)]
pub async fn update_user_quota(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1049,11 +1023,8 @@ pub async fn update_user_quota(
)]
pub async fn create_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -1090,12 +1061,9 @@ pub async fn create_user(
)]
pub async fn reset_user_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1141,10 +1109,10 @@ pub async fn reset_user_password(
)]
pub async fn set_registration_setting(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let enabled = body
.get("registration_enabled")
@@ -1177,10 +1145,7 @@ pub async fn set_registration_setting(
async fn reextract_audio_metadata(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let audio_service = state
.applications
.audio_metadata_service
@@ -1207,10 +1172,7 @@ async fn reextract_audio_metadata(
/// Photos timeline by real capture date. Safe to re-run (idempotent upsert).
async fn reextract_image_metadata(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let result = state
.applications
.media_metadata_service
@@ -1253,12 +1215,7 @@ async fn reextract_image_metadata(
security(("bearerAuth" = [])),
tag = "admin"
)]
async fn get_smtp_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
async fn get_smtp_info(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
let smtp = &state.core.config.smtp;
let info = SmtpInfoDto {
enabled: smtp.is_enabled() && state.email_sender.is_some(),
@@ -1287,11 +1244,8 @@ async fn get_smtp_info(
/// returns 404 to keep the endpoint inert.
async fn get_captured_email(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<CapturedEmailQuery>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
if !std::env::var("OXICLOUD_SMTP_MOCK")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
@@ -1347,10 +1301,10 @@ struct CapturedEmailQuery {
)]
async fn send_smtp_test(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SendSmtpTestDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let recipient = dto.to.trim().to_string();
if recipient.is_empty() {
@@ -1462,9 +1416,7 @@ fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
/// GET /api/admin/plugins — list installed plugins.
pub async fn list_plugins(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
// `enabled` reports that the plugin *subsystem* is active (reaching here
@@ -1479,11 +1431,11 @@ pub async fn list_plugins(
/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin.
pub async fn set_plugin_enabled(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<SetEnabledDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_enabled(&id, dto.enabled)
.map_err(|e| map_mgmt_err(&e))?;
@@ -1520,10 +1472,10 @@ pub async fn set_plugin_enabled(
/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`.
pub async fn install_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
let mut bundle: Option<Vec<u8>> = None;
@@ -1584,10 +1536,10 @@ pub async fn install_plugin(
/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files.
pub async fn delete_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?;
@@ -1609,11 +1561,9 @@ pub async fn delete_plugin(
/// structured log entries (newest first).
pub async fn get_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<PluginLogQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
@@ -1637,10 +1587,10 @@ pub async fn get_plugin_logs(
/// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs.
pub async fn clear_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?;
@@ -1664,13 +1614,11 @@ pub async fn clear_plugin_logs(
/// so `EventSource` works without setting headers.
pub async fn stream_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
if !mgmt.list().iter().any(|p| p.id == id) {
return Err(AppError::not_found("Plugin not found"));
@@ -1698,10 +1646,8 @@ pub async fn stream_plugin_logs(
/// GET /api/admin/plugins/{id}/retention — the plugin's effective retention.
pub async fn get_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let settings = mgmt
.get_retention(&id)
@@ -1713,11 +1659,11 @@ pub async fn get_plugin_retention(
/// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy.
pub async fn set_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<PluginRetentionDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_retention(&id, dto.into())
.await
@@ -1761,9 +1707,7 @@ pub async fn set_plugin_retention(
)]
pub async fn list_all_drives(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let drives = state
.drive_repo
.list_all()
@@ -1799,10 +1743,8 @@ pub async fn list_all_drives(
)]
pub async fn list_drive_members_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let grants = state
.authorization
.list_grants_on_resource(Resource::Drive(drive_id))
@@ -1862,11 +1804,11 @@ fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
)]
pub async fn add_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
Json(dto): Json<AdminAddDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(dto.subject.kind, dto.subject.id);
let grant = state
.drive_management_service
@@ -1907,7 +1849,7 @@ pub async fn add_drive_member_admin(
)]
pub async fn update_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
@@ -1915,7 +1857,7 @@ pub async fn update_drive_member_admin(
)>,
Json(dto): Json<AdminUpdateDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(kind, subject_id);
let grant = state
.drive_management_service
@@ -1954,14 +1896,14 @@ pub async fn update_drive_member_admin(
)]
pub async fn remove_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
Uuid,
)>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(kind, subject_id);
state
.drive_management_service
@@ -1996,10 +1938,10 @@ pub async fn remove_drive_member_admin(
)]
pub async fn delete_drive_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
state
.drive_management_service
.delete_drive(admin_id, true, drive_id)
@@ -2055,15 +1997,11 @@ fn internal_endpoints_disabled() -> axum::response::Response {
)]
pub async fn internal_trigger_sweep(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
if let Err(e) = admin_guard(&state, &headers).await {
return e.into_response();
}
let svc = match state.storage_usage_service.as_ref() {
Some(s) => s,
None => {
@@ -2135,16 +2073,12 @@ pub struct InternalTriggerGcQuery {
)]
pub async fn internal_trigger_gc(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<InternalTriggerGcQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
if let Err(e) = admin_guard(&state, &headers).await {
return e.into_response();
}
let result = if query.force {
state.core.dedup_service.garbage_collect_force().await
} else {
@@ -2211,16 +2145,12 @@ pub struct InternalTriggerGrantCleanupQuery {
)]
pub async fn internal_trigger_grant_cleanup(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<InternalTriggerGrantCleanupQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
if let Err(e) = admin_guard(&state, &headers).await {
return e.into_response();
}
// Daemon may be disabled by config even when the internal-endpoint
// gate is on. Return 503 (rather than 404 or 500) so integration
// tests can distinguish "surface not exposed" from "surface
@@ -188,9 +188,14 @@ impl ChunkedUploadHandler {
// ── Permission pre-check: caller must have Create on the target
// folder BEFORE we allocate a session and accept chunks. The
// upload service re-checks at finalize time, but failing here
// avoids wasting client+server resources on chunks that will be
// rejected. None = caller's root namespace, no check needed.
// upload service re-checks at finalize via
// `upload_file_streaming_with_perms` (AuthZ audit #17 fix,
// 2026-07-16) so a grant revoked mid-session is caught. This
// pre-check is the fail-fast: it avoids wasting client+server
// resources on chunks that will be rejected anyway. `None`
// means the write lands at drive-root — that path is currently
// unchecked (session doesn't carry `drive_id`; tracked with the
// folder-id-walking follow-up).
if let Some(ref fid) = request.folder_id
&& let Err(err) = state
.applications
@@ -441,9 +446,17 @@ impl ChunkedUploadHandler {
}
// Register the file row against the ingested blob.
//
// AuthZ audit #17 (2026-07-12): swapped `upload_file_streaming` →
// `upload_file_streaming_with_perms` so `Create` on the target
// folder is re-verified at finalize. Session creation already
// pre-checked (line ~198), but that was potentially hours or
// days ago; app-passwords keep sessions valid indefinitely.
// Without the finalize re-check, a grant revoked mid-session
// stayed effective until the last chunk landed.
let size = ingested.size;
match upload_service
.upload_file_streaming(
.upload_file_streaming_with_perms(
parts.filename.clone(),
parts.folder_id.clone(),
ingested.content_type.clone(),
@@ -478,7 +491,12 @@ impl ChunkedUploadHandler {
}
Err(e) => {
tracing::error!("Failed to create file from chunked upload: {:?}", e);
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
// AuthZ audit #2 (2026-07-12) — route DomainError through
// `AppError::from` so graduated denial from
// `upload_file_streaming_with_perms` keeps the 403/404
// shape instead of collapsing into a 500. Sibling
// `cancel_upload_impl` at :514 already uses this pattern.
AppError::from(e).into_response()
}
}
}
@@ -519,9 +537,15 @@ impl ChunkedUploadHandler {
// routes.rs calls these free functions directly.
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
/// **Deprecated.** Prefer `/api/files/delta/*` — hash-first negotiation,
/// resumable, chunked. The `/api/uploads/*` family stays for backward
/// compatibility with existing clients but receives no new features.
#[utoipa::path(
post,
path = "/api/uploads",
description = "**Deprecated.** Prefer the delta-upload surface at `/api/files/delta/*` \
(hash-first negotiation, resumable, chunked). The `/api/uploads/*` family is kept for \
backward compatibility with existing clients but is no longer receiving new features.",
request_body(content = CreateUploadRequest, content_type = "application/json", description = "Upload session parameters"),
responses(
(status = 201, description = "Upload session created", body = crate::application::ports::chunked_upload_ports::CreateUploadResponseDto),
@@ -531,6 +555,7 @@ impl ChunkedUploadHandler {
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn create_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -539,9 +564,11 @@ pub async fn create_upload(
ChunkedUploadHandler::create_upload_impl(state, auth_user, request).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
patch,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
("chunk_index" = usize, Query, description = "Zero-based chunk index"),
@@ -570,6 +597,7 @@ pub async fn create_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn upload_chunk(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
@@ -683,9 +711,11 @@ pub async fn upload_chunk(
.into_response()
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
head,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -696,6 +726,7 @@ pub async fn upload_chunk(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn get_upload_status(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -704,9 +735,11 @@ pub async fn get_upload_status(
ChunkedUploadHandler::get_upload_status_impl(state, auth_user, path).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
post,
path = "/api/uploads/{upload_id}/complete",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -731,6 +764,7 @@ pub async fn get_upload_status(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn complete_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -744,9 +778,11 @@ pub async fn complete_upload(
ChunkedUploadHandler::complete_upload_impl(state, auth_user, path, req).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
delete,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -757,6 +793,7 @@ pub async fn complete_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn cancel_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
+36 -27
View File
@@ -218,18 +218,16 @@ impl DedupHandler {
/// - Deduplication ratio
pub(super) async fn get_stats_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
_auth_user: AuthUser,
) -> impl IntoResponse {
// Admin-only — global dedup statistics are sensitive infrastructure data
if auth_user.role != "admin" {
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Admin role required"}"#))
.unwrap()
.into_response();
}
// AuthZ audit #24 (2026-07-17): admin check moved to the
// `/api/admin/*` middleware layer. Reaching this handler means
// the caller is admin by construction — the bespoke role
// string comparison here (`auth_user.role != "admin"` → 403
// with a hand-rolled JSON body, no audit line) is gone. The
// route is registered at `admin_handler::admin_routes()`;
// moving the URL to `/api/admin/dedup/stats` also declares
// the admin intent up front.
let dedup = &state.core.dedup_service;
let stats = dedup.get_stats().await;
@@ -343,16 +341,10 @@ impl DedupHandler {
State(state): State<GlobalState>,
auth_user: AuthUser,
) -> impl IntoResponse {
// Admin-only — integrity verification is a privileged operation
if auth_user.role != "admin" {
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Admin role required"}"#))
.unwrap()
.into_response();
}
// AuthZ audit #25 (2026-07-17): admin check moved to the
// `/api/admin/*` middleware layer — see the sibling
// `get_stats_impl` comment. `auth_user` is kept so the
// success-side audit line carries the caller id.
let dedup = &state.core.dedup_service;
// Verify integrity first
@@ -392,6 +384,21 @@ impl DedupHandler {
savings_percentage: savings_pct,
};
// AuthZ audit #25 (2026-07-17): integrity recalculation is a
// low-frequency privileged operation — landing an audit event
// so security reviews can see who ran verify + integrity
// sweeps and when. The pre-fix path emitted no audit line at
// all (the accepted 200 was silent from the security POV).
tracing::info!(
target: "audit",
event = "dedup.integrity_recalculated",
caller_id = %auth_user.id,
unique_blobs = response.unique_blobs,
total_references = response.total_references,
bytes_saved = response.bytes_saved,
"🧮 dedup integrity verified and stats recomputed by admin",
);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
@@ -453,12 +460,13 @@ pub async fn check_hashes_batch(
#[utoipa::path(
get,
path = "/api/dedup/stats",
path = "/api/admin/dedup/stats",
responses(
(status = 200, description = "Deduplication statistics", body = StatsResponse),
(status = 403, description = "Admin role required"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
),
tag = "dedup",
tag = "admin",
security(("bearerAuth" = []))
)]
pub async fn get_stats(state: State<GlobalState>, auth_user: AuthUser) -> impl IntoResponse {
@@ -489,13 +497,14 @@ pub async fn get_blob(
#[utoipa::path(
post,
path = "/api/dedup/recalculate",
path = "/api/admin/dedup/recalculate",
responses(
(status = 200, description = "Statistics after integrity verification", body = StatsResponse),
(status = 403, description = "Admin role required"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
(status = 500, description = "Integrity verification failed"),
),
tag = "dedup",
tag = "admin",
security(("bearerAuth" = []))
)]
pub async fn recalculate_stats(
+45 -22
View File
@@ -1,7 +1,7 @@
use axum::{
extract::{Json, Query, State},
http::StatusCode,
response::IntoResponse,
response::{IntoResponse, Response},
};
use serde_json::json;
use tracing::{error, info};
@@ -11,6 +11,7 @@ use crate::application::dtos::search_dto::{
};
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
@@ -187,40 +188,57 @@ impl SearchHandler {
}
}
/// DELETE /search/cache — clears the search results cache.
/// `DELETE /admin/search/cache` — flush the shared moka search
/// results cache. Admin-only.
///
/// AuthZ audit #14 (2026-07-12): pre-fix this endpoint lived at
/// `/api/search/cache` and required only a valid JWT — any
/// authenticated user (external / magic-link included) could
/// DELETE it in a loop and keep the results cache cold indefinitely
/// (sustained DoS on every subsequent `/api/search` query). Now
/// mounted at `/api/admin/search/cache`, gated by the
/// `require_admin` middleware layer on the `/api/admin` nest point.
/// The handler no longer needs an inline authz call — reaching
/// this code implies `AuthUser` is admin by construction. Audit
/// line on success so operator-driven flushes are traceable in
/// security reviews.
pub(super) async fn clear_search_cache_impl(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
auth_user: AuthUser,
) -> Result<Response, AppError> {
let caller_id = auth_user.id;
info!("API: Clearing search cache");
let search_service = match &state.applications.search_service {
Some(service) => service,
None => {
error!("Search service not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response();
}
let Some(search_service) = &state.applications.search_service else {
error!("Search service not available");
return Ok((
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response());
};
match search_service.clear_search_cache().await {
Ok(_) => {
info!("Search cache cleared successfully");
(
tracing::info!(
target: "audit",
event = "search.cache_cleared",
caller_id = %caller_id,
"🧹 search results cache flushed by admin",
);
Ok((
StatusCode::OK,
Json(json!({ "message": "Search cache cleared successfully" })),
)
.into_response()
.into_response())
}
Err(err) => {
error!("Error clearing search cache: {}", err);
(
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Error clearing search cache" })),
)
.into_response()
.into_response())
}
}
}
@@ -368,14 +386,19 @@ pub async fn suggest_files(
#[utoipa::path(
delete,
path = "/api/search/cache",
path = "/api/admin/search/cache",
responses(
(status = 200, description = "Cache cleared"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
tag = "admin"
)]
pub async fn clear_search_cache(state: State<Arc<AppState>>) -> impl IntoResponse {
SearchHandler::clear_search_cache_impl(state).await
pub async fn clear_search_cache(
state: State<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<Response, AppError> {
SearchHandler::clear_search_cache_impl(state, auth_user).await
}
+41 -7
View File
@@ -332,23 +332,57 @@ async fn put_file(
};
// ── Atomic store: swap the file row onto the ingested blob ──
// `drive_id` scopes the path-based lookups in `update_file_streaming`
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
// that to the caller's default drive (WOPI today is a single-drive
// editing surface — no drive marker travels in the token).
// `drive_id` scopes the path-based lookups in
// `update_file_streaming_with_perms` post-D0.
//
// AuthZ audit #18 (2026-07-12): the pre-fix path resolved
// `drive_id` via `find_default_for_user(claims_sub_uuid)` —
// ALWAYS the caller's own default personal drive, regardless of
// where the file actually lived. Shared-drive edits either
// misrouted the write into the caller's personal drive (if the
// filename happened to collide with a personal-drive path) or
// 500'd on the parent-folder lookup. Resolve from the file's
// own parent folder instead — one PK probe, returns the drive
// the file genuinely belongs to. Also unlocks shared-drive WOPI
// editing.
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let Some(folder_id_str) = file.folder_id.as_deref() else {
// Files always live under a folder (drive-root files use the
// drive-root folder id). A `None` here means the file entity
// is malformed — safest is a 500.
tracing::error!(
"WOPI PutFile: file {} has no parent folder id — cannot resolve drive",
file_id
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let folder_uuid = match uuid::Uuid::parse_str(folder_id_str) {
Ok(u) => u,
Err(_) => {
tracing::error!(
"WOPI PutFile: file {} parent folder id '{}' is not a UUID",
file_id,
folder_id_str
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let drive_id = match state
.app_state
.drive_repo
.find_default_for_user(claims_sub_uuid)
.drive_id_for_folder(folder_uuid)
.await
{
Ok(d) => d.drive.id,
Ok(id) => id,
Err(e) => {
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
tracing::error!(
"WOPI PutFile: drive-id lookup for folder {} failed: {:?}",
folder_uuid,
e
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
+40 -13
View File
@@ -52,6 +52,11 @@ async fn get_openapi_spec() -> AxumJson<utoipa::openapi::OpenApi> {
use crate::interfaces::api::handlers::admin_handler;
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
// `chunked_upload_handler::*` are marked `#[deprecated]` (prefer
// `/api/files/delta/*`); the router still needs to reference them
// until clients migrate. See the `chunked_upload_router` block
// below for the local `#[allow(deprecated)]`.
#[allow(deprecated)]
use crate::interfaces::api::handlers::chunked_upload_handler::{
cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk,
};
@@ -70,7 +75,7 @@ use crate::interfaces::api::handlers::i18n_handler::{
get_locales, get_translations_by_locale, translate,
};
use crate::interfaces::api::handlers::search_handler::{
clear_search_cache, search_files_get, search_files_post, suggest_files,
search_files_get, search_files_post, suggest_files,
};
use crate::interfaces::api::handlers::trash_handler;
@@ -275,8 +280,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/suggest", get(suggest_files))
// Advanced search with full criteria object
.route("/advanced", post(search_files_post))
// Clear search cache
.route("/cache", delete(clear_search_cache))
// `DELETE /api/search/cache` used to live here as a per-user-
// reachable endpoint. It's an operator-only debug lever
// (moka `invalidate_all()` — nukes every tenant), so it
// moved to `/api/admin/search/cache` where the URL declares
// intent. AuthZ audit #14 (2026-07-16).
.with_state(app_state.clone())
} else {
Router::new()
@@ -365,6 +373,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create routes for chunked uploads (large files >10MB).
// All five handlers are free functions — see chunked_upload_handler.rs for why
// #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly.
//
// Each handler carries `#[deprecated]` so utoipa marks the OpenAPI paths
// deprecated (Swagger UI shows the strikethrough + banner) and existing
// callers get a compile-time nudge to migrate to `/api/files/delta/*`.
// The route registration itself has to keep referencing them until the
// clients migrate off, so we suppress the local `deprecated` lint here.
#[allow(deprecated)]
let chunked_upload_router = Router::new()
.route("/", post(create_upload))
.route("/{upload_id}", axum::routing::patch(upload_chunk))
@@ -376,18 +391,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create routes for deduplication endpoints.
// All handlers are free functions — see dedup_handler.rs for why
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
use super::handlers::dedup_handler::{
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats,
};
use super::handlers::dedup_handler::{check_hash, check_hashes_batch, get_blob};
let dedup_router = Router::new()
.route("/check/{hash}", get(check_hash))
.route("/check-batch", post(check_hashes_batch))
.route("/stats", get(get_stats))
.route("/blob/{hash}", get(get_blob))
// NOTE: remove_reference is intentionally NOT exposed as a public
// endpoint — ref_count management is an internal concern handled
// automatically when files are deleted via the file API.
.route("/recalculate", post(recalculate_stats))
// NOTE: `remove_reference` is intentionally NOT exposed as a
// public endpoint — ref_count management is an internal concern
// handled automatically when files are deleted via the file API.
//
// `/stats` and `/recalculate` moved to `/api/admin/dedup/*`
// (AuthZ audit #24/#25, 2026-07-17) so the middleware admin
// gate covers them by construction. See
// `admin_handler::admin_routes()`.
.with_state(app_state.clone());
let mut router = Router::new()
@@ -598,8 +614,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav)
// in main.rs for protocol compliance, NOT under /api.
// Admin settings routes (protected by admin_guard inside the handler)
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
// Admin settings routes — the whole subtree is admin-only by
// construction. The `require_admin` layer runs AFTER the outer
// `auth_middleware` (main.rs::protected_api), so it can rely on
// `CurrentUser` already being in the request extensions. Any new
// route added to `admin_handler::admin_routes()` inherits the
// gate automatically — implementors no longer have to remember
// to call `require_admin(&state, &headers).await?` inline, and a
// forgotten call can't silently expose a non-admin surface.
let admin_router = admin_handler::admin_routes()
.layer(axum::middleware::from_fn(
crate::interfaces::middleware::auth::require_admin,
))
.with_state(app_state.clone());
router = router.nest("/admin", admin_router);
// ReBAC subject-group management. All mutating routes are admin-gated;
+15 -10
View File
@@ -392,6 +392,13 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response {
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
/// here within the flags-cache TTL.
///
/// Denial shapes distinguish authn from authz:
/// - `CurrentUser` present, role != "admin" → 403 Forbidden.
/// - `CurrentUser` absent → 401 Unauthorized. Should not happen in
/// practice (auth_middleware guards against it), but the
/// defensive fallback returns the honest shape: "we don't know
/// who you are" is 401, not "we know you and refuse" (403).
pub async fn require_admin(request: Request, next: Next) -> Response {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
@@ -407,18 +414,16 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
} else {
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
return AuthError::AccessDenied("Admin role required".to_string()).into_response();
}
// Access denied
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
AuthError::TokenNotProvided.into_response()
}
#[cfg(test)]
+27 -6
View File
@@ -135,19 +135,40 @@ async fn user_provisioning_response(
) -> Response {
let statuscode = if ocs_version == 1 { 100 } else { 200 };
// Only allow users to view their own profile, unless they are admin.
if user.username != userid && user.role != "admin" {
return Json(ocs_err(403, "Insufficient privileges")).into_response();
}
// AuthZ audit #11 (2026-07-12): the pre-fix path here rolled its
// own gate ("caller is `userid`, else must be admin") and then
// called bare `get_user_by_username` — bypassing every visibility
// rule the id-keyed `/api/users/{id}` endpoint enforces. Cross-user
// probes returned 403 (leaking existence via the differential vs a
// genuine 404 for missing users); admins bypassed
// `expose_system_users`; no audit line ever fired.
//
// Now routing through `get_user_profile_by_username_with_perms`,
// which delegates to the same visibility engine as the REST
// endpoint (self / shared-grant / expose_system_users / admin
// paths, all audit-logged on denial). The OCS wire shape stays
// `ocs_err(404, ...)` for every denied case — the NC client can't
// tell "no such user" from "you can't see this user" from "you're
// not admin" apart, which is the anti-enum invariant.
let auth_service = match state.auth_service.as_ref() {
Some(svc) => &svc.auth_application_service,
None => {
return Json(ocs_err(997, "Authentication not configured")).into_response();
}
};
let Some(pool) = state.db_pool.as_ref() else {
return Json(ocs_err(997, "Database pool not available")).into_response();
};
let user_dto = match auth_service.get_user_by_username(&userid).await {
let user_dto = match auth_service
.get_user_profile_by_username_with_perms(
user.id,
&userid,
state.core.config.features.expose_system_users,
pool,
)
.await
{
Ok(u) => u,
Err(_) => {
return Json(ocs_err(404, "User not found")).into_response();
+35 -60
View File
@@ -6,7 +6,7 @@ use axum::{
use std::sync::Arc;
use uuid::Uuid;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
@@ -402,8 +402,6 @@ async fn handle_assemble(
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
let upload_service = &state.applications.file_upload_service;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Path-based lookups below scope by `drive_id`. The NC session's
// chroot is always populated for path-scoped handlers (see
@@ -433,64 +431,41 @@ async fn handle_assemble(
.await?;
let content_type = ingested.content_type.clone();
// Check if file exists (update vs create).
let existing = file_service
.get_file_by_path(&internal_path, drive_id)
.await;
let etag: Option<String> = if existing.is_ok() {
let dto = upload_service
.update_file_streaming_with_perms(
&internal_path,
drive_id,
ingested.stored(),
&content_type,
oc_mtime,
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
Some(dto.etag)
} else {
// New-file branch: resolve the parent folder by path and register
// the file row against the already-ingested blob.
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?;
let parent_internal = parent_internal.trim_end_matches('/');
use crate::application::ports::folder_ports::FolderUseCase;
let parent_folder = match folder_service
.get_folder_by_path(parent_internal, drive_id)
.await
{
Ok(folder) => folder,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
return Err(AppError::internal_error(format!(
"Parent folder lookup failed: {}",
e
)));
}
};
let dto = upload_service
.upload_file_streaming(
filename.to_string(),
Some(parent_folder.id),
content_type.to_string(),
ingested.stored(),
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
Some(dto.etag)
// AuthZ audit #12 (2026-07-12): the previous shape branched on
// file existence — `update_file_streaming_with_perms` on the
// overwrite path (correct), plain `upload_file_streaming` on
// the create path (NO `authz.require`). Viewer/Commenter on a
// shared drive could MKCOL → PUT chunks → MOVE and land a
// brand-new file, skipping the `Create`-on-parent-folder gate.
//
// `update_file_streaming_with_perms` handles both branches
// atomically: `Update` on the existing file OR `Create` on the
// parent folder / drive root (per the service's own internal
// fork). Funneling everything through the one method also
// deletes the duplicated parent-folder lookup that used to
// live here.
//
// AuthZ audit #2 (2026-07-12): route DomainError through
// `AppError::from` so authz denials keep the graduated 403/404
// shape instead of collapsing into 500.
let dto = match upload_service
.update_file_streaming_with_perms(
&internal_path,
drive_id,
ingested.stored(),
&content_type,
oc_mtime,
user.id,
)
.await
{
Ok(dto) => dto,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
return Err(AppError::from(e));
}
};
let etag: Option<String> = Some(dto.etag);
// Cleanup session.
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
+109
View File
@@ -455,6 +455,115 @@ Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 21d–21g — Regression pin for AuthZ audit #13 (2026-07-12).
#
# `ContactService::delete_contact` used to `authz.require(Update)`
# on the address book instead of `Delete`. Editor role bundle
# (Read + Comment + Create + Update) satisfies Update → any
# Editor grantee on a shared address book could delete individual
# contacts. Fix: swap the required Permission on delete_contact
# + delete_group to `Delete`. Sibling `CalendarService::delete_event`
# was the ground-truth pattern.
#
# The pin promotes Bob to Editor (so his bundle includes Update
# but NOT Delete — exactly the pre-fix bypass condition), seeds a
# canary contact as Alice, has Bob attempt DELETE, then confirms
# Alice still sees the contact. Pre-fix would 204; post-fix 403.
# ─────────────────────────────────────────────────────────────
# 21d — Promote Bob from Viewer to Editor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "editor"
}
HTTP 200
# 21e — Alice seeds a canary contact in the shared book.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"full_name": "audit-13 delete-permission canary"
}
HTTP 201
[Captures]
audit13_contact_id: jsonpath "$.id"
# 21f — Bob (Editor) DELETE the canary → 403. Editor has Read
# so graduated denial fires with `visibility=visible`. Pre-fix
# this returned 204 because `require(Update)` succeeded on the
# Editor bundle.
DELETE {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{bob_token}}
HTTP 403
# 21g — Alice re-fetches to confirm the canary is still there
# (Bob's DELETE really was refused, not just responded to).
GET {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{audit13_contact_id}}"
# ─────────────────────────────────────────────────────────────
# Step 21h–21i — Regression pin for AuthZ audit #19 (2026-07-12).
#
# `ContactService::create_contact` + `create_contact_from_vcard`
# + `create_group` used to `authz.require(Update)` on the address
# book, which the Contributor bundle (Read + Create) does NOT
# satisfy — so Contributor grantees were blocked from adding
# contacts via REST or CardDAV PUT despite holding the intended
# Create permission. Not a bypass, an over-restrictive gate.
# Fix: `Permission::Create`. Sibling `#13` above closed the
# mirror bug on the delete verbs.
#
# The pin demotes Bob from Editor (Step 21d) to Contributor —
# Contributor is the minimal role that MUST succeed post-fix and
# FAILED pre-fix. Bob then POSTs a contact via REST; pre-fix this
# 403'd, post-fix returns 201.
# ─────────────────────────────────────────────────────────────
# 21h — Demote Bob from Editor to Contributor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "contributor"
}
HTTP 200
# 21i — Bob (Contributor) creates a contact → 201. Pre-fix, the
# service required Update which Contributor's bundle doesn't hold,
# so this 403'd and the CardDAV surface was equally blocked.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"full_name": "audit-19 contributor-can-create canary"
}
HTTP 201
[Captures]
audit19_contact_id: jsonpath "$.id"
# Step 22 — Alice revokes the grant.
DELETE {{base_url}}/api/grants/{{share_grant_id}}
Authorization: Bearer {{token}}
+132
View File
@@ -0,0 +1,132 @@
# =============================================================
# OxiCloud — Dedup admin gate + URL move
# =============================================================
# Regression pin for AuthZ audit #24 + #25 (2026-07-12).
#
# `dedup_handler.rs` previously rolled its own admin check on
# `/api/dedup/stats` and `/api/dedup/recalculate` — a bespoke
# `if auth_user.role != "admin" { 403 with hand-rolled JSON }`
# with no audit line on rejection. That's the same drift class
# the admin middleware layer refactor closed elsewhere on
# 2026-07-17.
#
# Fix:
# 1. Both endpoints moved to `/api/admin/dedup/*` where the
# `/api/admin` middleware gate covers them by construction.
# URL declares admin intent up front.
# 2. Inline role check removed from the handlers — reaching
# them at all means the caller is admin.
# 3. `recalculate` emits `dedup.integrity_recalculated` on
# success (audit #25). Not asserted here (no log-scrape
# harness in Hurl); the shape is pinned in the handler
# code and covered by the `audit` tracing target contract.
#
# This test pins:
# * Admin can hit both endpoints at the new URL → 200.
# * Non-admin (bob) hits both → 403 (middleware layer).
# * The OLD URLs `/api/dedup/stats` and `/api/dedup/recalculate`
# are no longer registered → 404. Trips if someone
# re-introduces the routes to `dedup_router` without also
# removing them from `admin_handler::admin_routes()`.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login + bob (re-)provisioning.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# Anti-enum registration.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "dedup_bob",
"email": "dedup_bob@example.com",
"password": "DedupBobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "dedup_bob", "password": "DedupBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin can hit the new URL. `stats` returns a
# `StatsResponse`-shaped body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
jsonpath "$.bytes_saved" isNumber
jsonpath "$.total_logical_bytes" isNumber
jsonpath "$.total_physical_bytes" isNumber
# ─────────────────────────────────────────────────────────────
# Step 2 — Admin can trigger the integrity recalculation.
# Response shape mirrors `stats`. Server-side, this
# also emits the `dedup.integrity_recalculated` audit
# event (not asserted from Hurl).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
# ─────────────────────────────────────────────────────────────
# Step 3 — Bob (non-admin) is denied. The `/api/admin/*`
# middleware layer emits `AuthError::AccessDenied` →
# 403. No hand-rolled 403 body from the handler; the
# handler doesn't even run.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{bob_token}}
HTTP 403
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 4 — The old URLs are no longer registered. Trips if a
# future refactor re-adds them to `dedup_router` without
# removing them from `admin_handler::admin_routes()` (or
# vice versa). Anti-enum catch-all in the `/api/*` router
# returns 404 for unknown paths.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 404
POST {{base_url}}/api/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 404
+1 -1
View File
@@ -14,7 +14,7 @@
# (proves blob NOT prematurely deleted — bug 3 detection)
# 4. Permanently delete file 2 → blob and thumbnail cleaned up
#
# NOTE: The /api/dedup/stats endpoint counts CDC chunk rows in
# NOTE: The /api/admin/dedup/stats endpoint counts CDC chunk rows in
# storage.blobs and derives bytes_saved from chunk_manifests.
# Both tables may be 0 when the CDC path is disabled or the
# server uses the legacy blob path — so we avoid stats-based
+58 -20
View File
@@ -111,16 +111,25 @@ HTTP 201
small_file_id: jsonpath "$.id"
# Confirm `drives.used_bytes` reflects the new file. The hook is
# fire-and-forget on a tokio task, so the SQL UPDATE may not have
# landed by the time `POST /api/files/upload` returned. Retry the
# `GET /api/drives` until the cached value catches up — bounded
# wait keeps a slow CI machine from flaking.
# Force freshness on `drives.used_bytes`:
# 1. The fire-and-forget delta hook may not have landed yet
# (200 ms delay to let the tokio task register — see
# `bug_trigger_sweep_vs_spawn_hook_race`).
# 2. Force a reconciliation sweep. That's the ONLY path that
# invalidates `readable_cache` / `default_drive_cache` after
# Ed's 2026-07-17 design call: the sweep is the escape hatch
# for tests / operators that need immediate cache freshness;
# per-write invalidation would nuke the cache on every upload.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -145,13 +154,19 @@ file: file,fixtures/hello-copy.txt; text/plain
HTTP 201
# `used_bytes` climbs to 64 (32 + 32). Same retry shape as the
# first assertion since the second delta is also fire-and-forget.
# `used_bytes` climbs to 64 (32 + 32). Same trigger-sweep pattern
# as the first assertion — the delta is fire-and-forget and the
# listing cache lags until the sweep invalidates it.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -173,7 +188,18 @@ HTTP 507
# `used_bytes` is unchanged — the failed upload didn't charge the
# drive. (Cumulative usage is still 64; the 5 MiB write never
# registered a row.)
# registered a row.) Trigger the sweep again to guarantee cache
# freshness — the 5 MiB attempt was refused pre-write so no
# delta was queued, but the previous sweep's invalidation was
# consumed by the intervening GET which re-populated the cache
# with the pre-refused-write value. Sweep + re-check for
# determinism.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
@@ -211,13 +237,17 @@ HTTP 201
# Unlimited drive's `used_bytes` climbs to the file's exact size
# (5 MiB = 5_242_880 bytes). Same retry block because the delta
# hook is fire-and-forget here too.
# (5 MiB = 5_242_880 bytes). Trigger-sweep pattern (see above).
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -384,7 +414,15 @@ HTTP 200
# `used_bytes` on the tight drive is unchanged — the two refused
# operations above never wrote anything.
# operations above never wrote anything. Trigger-sweep so the
# check reads live SQL (see the class doc on the earlier
# sweep + GET pair for the design rationale).
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
+90
View File
@@ -818,6 +818,96 @@ Authorization: Bearer {{adam_token}}
HTTP 204
# ── Regression pin for AuthZ audit #17 (2026-07-12). ─────────
# The chunked-upload `complete` handler used to call plain
# `upload_file_streaming` at finalize — no `_with_perms` check.
# A grant revoked between session-open and finalize stayed
# effective until the last chunk landed (up to 24h JWT TTL,
# forever with app-passwords). Fix: swap to
# `upload_file_streaming_with_perms` so `authz.require(Create,
# Folder)` re-runs at complete time.
#
# Sequence:
# 1. Adam (Editor) opens a session — pre-check passes.
# 2. Adam PATCHes the single chunk (chunk upload is unauth'd,
# always allowed).
# 3. Alice DEMOTES Adam to Viewer (Viewer bundle has Read but
# no Create).
# 4. Adam POST /complete → 403 (pre-fix: 201 + file created).
# 5. Cleanup: cancel the orphaned session + re-promote Adam
# to Editor so the following steps aren't disturbed.
# 1 — Open session while Editor.
POST {{base_url}}/api/uploads
Authorization: Bearer {{adam_token}}
Content-Type: application/json
{
"filename": "audit17-post-revoke.mp4",
"folder_id": "{{perm_folder_id}}",
"content_type": "video/mp4",
"total_size": 2760653,
"chunk_size": 3000000
}
HTTP 201
[Captures]
audit17_upload_id: jsonpath "$.upload_id"
# 2 — Send the single chunk (session pre-authorised).
PATCH {{base_url}}/api/uploads/{{audit17_upload_id}}?chunk_index=0
Authorization: Bearer {{adam_token}}
Content-Type: application/octet-stream
file,fixtures/free_video_over_1MB.mp4;
HTTP 200
# 3 — Alice demotes Adam Editor → Viewer (Create removed).
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{adam_user_id}}" },
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
"role": "viewer"
}
HTTP 200
# 4 — Finalize now fails: engine re-checks Create at complete
# time. Adam still has Read (viewer role) → graduated denial
# returns 403; pre-fix returned 201 with a phantom file.
POST {{base_url}}/api/uploads/{{audit17_upload_id}}/complete
Authorization: Bearer {{adam_token}}
HTTP 403
# 5a — The session is orphaned (chunks on disk, no completion).
# Cancel it as Adam (still owns the session, so the `_with_perms`
# gate on DELETE-session lets him through).
DELETE {{base_url}}/api/uploads/{{audit17_upload_id}}
Authorization: Bearer {{adam_token}}
HTTP 204
# 5b — Restore Adam to Editor so subsequent steps behave as
# before this regression pin was inserted.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{adam_user_id}}" },
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
"role": "editor"
}
HTTP 200
# ── Delete still denied (Editor excludes Delete). Editor has
# Read → graduated denial returns 403.
DELETE {{base_url}}/api/files/{{perm_file_id}}
+45 -14
View File
@@ -3,18 +3,25 @@
# =============================================================
# C4 from BASELINE_TESTS_NC_WEBDAV.md.
#
# Deferred from Batch 1 because it needed the bob fixture
# that `nc_second_user_setup.hurl` now provides. Pins the
# behaviour of the existing rule in
# `interfaces/nextcloud/ocs_handler.rs::user_provisioning_response`:
# Post AuthZ audit #11 (2026-07-17), `user_provisioning_response`
# no longer rolls its own admin gate — it delegates to
# `AuthApplicationService::get_user_profile_by_username_with_perms`,
# which shares the visibility engine with the id-keyed REST
# endpoint at `/api/users/{id}`. Consequences for this test:
#
# if user.username != userid && user.role != "admin" {
# return Json(ocs_err(403, ...)).into_response();
# }
#
# i.e. you can read your own profile always; you can read
# anyone's profile if you're admin. Bob is not admin, so bob
# CANNOT read admin's profile (the symmetric assertion).
# - **admin → bob**: still 200 (admin bypass is one of the
# five visibility paths; see get_user_profile step 5).
# - **bob → admin**: with `OXICLOUD_EXPOSE_SYSTEM_USERS=true`
# (tests/common/server.env), both are internal so step 4
# of the visibility engine says the target is broadly
# visible via the system address book — bob CAN see
# admin's basic profile. Pre-fix, the bespoke gate returned
# `403 Insufficient privileges` and admin bypassed the
# expose gate silently; both anomalies are gone.
# - **bob → nonexistent**: `404 User not found`, anti-enum
# shape identical to "you can't see this user". Audit line
# `user_profile.rejected reason=target_username_not_found`
# fires server-side.
#
# Uses admin's app password for Basic Auth (same pattern as
# `nc_ocs_user_info.hurl`).
@@ -82,8 +89,12 @@ jsonpath "$.ocs.data.email" == "bob@example.com"
# ─────────────────────────────────────────────────────────────
# C4-symmetric — bob (non-admin) CANNOT read admin's profile
# (proves the admin-only branch isn't a no-op)
# C4-symmetric — post-audit-#11: bob CAN read admin's profile
# because the visibility engine's
# `expose_system_users` branch treats internal
# users as broadly visible via the system address
# book. The bespoke `403 Insufficient privileges`
# the pre-fix handler emitted is gone.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
[BasicAuth]
@@ -91,7 +102,27 @@ GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 403
jsonpath "$.ocs.meta.statuscode" == 100
jsonpath "$.ocs.data.id" == "{{username}}"
# ─────────────────────────────────────────────────────────────
# C4-antienum — bob queries a genuinely nonexistent username.
# Response body is the SAME shape as any denial
# case: `statuscode=404 status="failure"`. The
# NC client cannot distinguish "user doesn't
# exist" from "you have no visibility on that
# user" (were expose_system_users off) — which
# is the anti-enumeration invariant this fix
# was meant to preserve.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/nonexistent-audit-11-canary?format=json
[BasicAuth]
{{bob_nc_user}}: {{bob_nc_pw}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 404
jsonpath "$.ocs.meta.status" == "failure"
+3 -1
View File
@@ -164,6 +164,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/recent.hurl" \
"$API_DIR/batch_folder_copy.hurl" \
"$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/dedup_admin_gate.hurl" \
"$API_DIR/default_caldav_carddav.hurl" \
"$API_DIR/dav_error_mapping.hurl" \
"$API_DIR/carddav_vcard_properties.hurl" \
@@ -207,7 +208,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/webdav_drive_root.hurl" \
"$API_DIR/webdav_permissions.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl" \
"$API_DIR/wopi_authz.hurl"
"$API_DIR/wopi_authz.hurl" \
"$API_DIR/wopi_shared_drive.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+49
View File
@@ -25,6 +25,22 @@
# =============================================================
# ─────────────────────────────────────────────────────────────
# Pre-setup — anonymous request pin.
#
# `DELETE /api/admin/search/cache` with NO credentials must land as
# 401 Unauthorized (from `auth_middleware`, before the admin gate
# even runs). Kept at the very top of the file so no earlier
# request has populated any auth state that could accidentally
# authenticate this request. `[Options] cookie-storage-clear` was
# tried earlier but isn't supported in Hurl 8.0.1, so we rely on
# ordering instead — this DELETE runs FIRST, before any login.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/search/cache
HTTP 401
# ─────────────────────────────────────────────────────────────
# Setup — admin login + bob (re-)provisioning
# ─────────────────────────────────────────────────────────────
@@ -251,6 +267,39 @@ jsonpath "$.filtered" not exists
jsonpath "$.total" not exists
# ─────────────────────────────────────────────────────────────
# 6b — Regression pin for AuthZ audit #14 (2026-07-12).
# `DELETE /api/admin/search/cache` calls moka `invalidate_all()`
# on the shared results cache — one call cold-starts every
# subsequent search for every tenant. Pre-fix, this lived at
# `/api/search/cache` gated only by the top-level auth
# middleware: any authenticated caller (including external /
# magic-link accounts) could DELETE it in a loop and hold the
# results cache empty indefinitely (sustained DoS). Fix: gate
# on `require_admin` AND move the URL to `/api/admin/...` so
# the taxonomy declares the intent up front. Moved 2026-07-17.
#
# Bob (regular user) → 403; missing token → 401; admin → 200.
# The 200 confirms the admin path still works (no regression
# on the operator debug lever the endpoint remains for).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/search/cache
Authorization: Bearer {{bob_token}}
HTTP 403
# The unauthenticated 401 case is pinned at the top of the file
# (before any login has run) — see the pre-setup block. Placing it
# there instead of here avoids relying on Hurl's cookie / auth
# behaviour, which `cookie-storage-clear` (unsupported in 8.0.1)
# would otherwise be needed to reset.
DELETE {{base_url}}/api/admin/search/cache
Authorization: Bearer {{admin_token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# 7 — Teardown: removing the folder recursively takes the files
# with it, so a single DELETE is enough.
+80 -6
View File
@@ -190,8 +190,12 @@ HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 9 — Provision a Viewer of the shared drive (`tpd_viewer`),
# then assert the per-drive empty refuses for Viewer / Editor
# / non-member callers. Each refusal is 404 (anti-enum).
# then assert the per-drive empty refuses for Viewer /
# Editor / non-member callers. Graduated denial (see
# [[project_authz_require_graduated_denial]]): the Viewer
# and Editor tests get 403 because they hold Read on the
# drive; the non-member fallback keeps the 404 anti-enum
# shape (no Read = no existence oracle).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
@@ -249,17 +253,19 @@ Authorization: Bearer {{owner_token}}
HTTP 204
# Test 4 — Viewer cannot empty the drive's trash.
# Test 4 — Viewer cannot empty the drive's trash. Viewer has Read
# on the drive → graduated denial returns 403.
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
Authorization: Bearer {{viewer_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 10 — Test 5: Editor cannot either.
# Promote tpd_viewer to Editor; same refusal. Confirms
# `Delete` isn't in the Editor bundle.
# `Delete` isn't in the Editor bundle. Editor has Read →
# graduated denial returns 403.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{viewer_user_id}}
Authorization: Bearer {{owner_token}}
@@ -272,7 +278,7 @@ HTTP 200
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
Authorization: Bearer {{viewer_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -315,6 +321,74 @@ HTTP 200
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11b — Regression pin for AuthZ audit #10 (2026-07-12).
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}`
# once did `err_str.contains("not found")` to decide "already
# gone" vs real failure — an authz denial (which returns a
# `NotFound`-shaped DomainError to preserve anti-enum on the
# listing side) matched the substring and got synthesised
# into a 200 `{"success": true}` response. Response lied;
# no mutation happened.
#
# Post-fix: both handlers route through
# `AppError::from(e).into_response()`, so authz denials
# surface as the graduated 403 / 404 shape and body is
# never a success envelope.
#
# The Editor (from Step 10 promotion) holds Read on the
# canary — graduated denial returns 403 with a
# `AccessDenied`-shape body, NOT a success envelope. If a
# future refactor reintroduces the substring hack this
# assertion trips before it lands in prod.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{viewer_token}}
HTTP 200
[Captures]
# The shared drive's trash holds exactly one item at this point (the
# canary owner trashed after Step 9), so `$.items[0]` is unambiguous
# — no filter needed. `TrashResourceItemDto` wraps the underlying
# resource in `.resource` (untagged File | Folder | Drive enum) and
# the trash key equals the original resource id (see
# `storage.trash_items` view), so `.resource.id` is exactly what
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}` accept.
# The `[?(...)]` + `nth 0` shape (see the sibling
# feedback_hurl_jsonpath_filter_empty memory) collapses on a single
# match and returns a scalar hurl can't index, so we avoid it here.
canary_trash_id: jsonpath "$.items[0].resource.id"
POST {{base_url}}/api/trash/{{canary_trash_id}}/restore
Authorization: Bearer {{viewer_token}}
HTTP 403
[Asserts]
body not contains "\"success\":true"
DELETE {{base_url}}/api/trash/{{canary_trash_id}}
Authorization: Bearer {{viewer_token}}
HTTP 403
[Asserts]
body not contains "\"success\":true"
# The canary is still there — the two Editor attempts didn't mutate.
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
# Owner sees TWO trash items at this point — the shared drive's
# canary (from Step 9) plus their personal drive's leftover from
# Step 4 (owner emptied only the shared drive's trash at Step 6).
# `contains` avoids depending on the sort order between them.
jsonpath "$.items[*].resource.id" contains "{{canary_trash_id}}"
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup: drop the canary, then the shared drive itself
# (D3b's delete-drive guard refuses non-empty drives, so
+19 -7
View File
@@ -130,15 +130,27 @@ file: file,fixtures/hello.txt; text/plain
HTTP 201
# Wait for the drive-side fire-and-forget delta to settle.
# Acts as the synchronisation point: by the time `drives.used_bytes`
# reflects the upload, the sibling user-side delta task spawned in
# the same call has had its chance to run too.
# Force freshness on `drives.used_bytes`:
# 1. 200 ms delay to let the fire-and-forget tokio task from the
# upload above land its SQL write (see
# `bug_trigger_sweep_vs_spawn_hook_race`).
# 2. Trigger the reconciliation sweep — the ONLY path that
# invalidates `readable_cache` / `default_drive_cache` after
# Ed's 2026-07-17 design call (per-write invalidation would
# nuke the cache on every upload, defeating the point). Also
# acts as the synchronisation point for the user-envelope
# assertion below — the sweep is the authoritative
# ground-truth for both drive- and user-side counters.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
+162
View File
@@ -0,0 +1,162 @@
# =============================================================
# OxiCloud — WOPI PutFile against a shared drive
# =============================================================
# Regression pin for AuthZ audit #18 (2026-07-12).
#
# `wopi_handler.rs::put_file` used to resolve the write's target
# drive via `drive_repo.find_default_for_user(claims_sub_uuid)` —
# ALWAYS the caller's own default personal drive, regardless of
# where the file being edited actually lived. Consequences for a
# shared-drive file:
#
# - If the file's path happened to collide with a personal-drive
# path, the write MISROUTED into the caller's personal drive
# (silent cross-drive data ejection).
# - Otherwise the parent-folder lookup inside
# `update_file_streaming_with_perms` missed and the request
# 500'd — a UX brick on shared-drive WOPI editing.
#
# Fix: resolve `drive_id` from the FILE's own parent folder via
# `drive_repo.drive_id_for_folder(file.folder_id)`. Same file →
# same drive → write lands in the shared drive it belongs to.
#
# This test:
# 1. Admin creates a shared drive (D3a shape).
# 2. Admin uploads `hello.txt` to the shared drive's root.
# 3. Admin mints a WOPI edit token.
# 4. Admin PutFile with fresh content → 200.
# Pre-fix this 500'd because the personal-drive-scoped
# parent-folder lookup couldn't find a folder named "" in
# admin's personal drive.
# 5. Admin GetFile → the shared drive holds the new content.
# Proves the write landed on the correct drive.
#
# Prereqs: `OXICLOUD_WOPI_ENABLED=true`, `OXICLOUD_WOPI_SECRET`
# pinned, mock discovery running (all wired in
# `tests/common/server.env` + run.sh — same as `wopi_authz.hurl`).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin creates a shared drive owned by themselves.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "wopi-shared-drive-audit-18",
"owner": { "type": "user", "id": "{{admin_user_id}}" }
}
HTTP 201
[Captures]
wopi_drive_id: jsonpath "$.id"
wopi_drive_root_id: jsonpath "$.root_folder_id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Upload `hello.txt` to the shared drive's root.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{wopi_drive_root_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
wopi_file_id: jsonpath "$.id"
[Asserts]
jsonpath "$.mime_type" == "text/plain"
# ─────────────────────────────────────────────────────────────
# Step 3 — Mint an editor URL. Admin has Update on their own
# shared drive → `can_write=true` in the token.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{wopi_file_id}}&action=edit
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
wopi_edit_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 4 — CheckFileInfo — sanity check the token is redeemable
# and reports `UserCanWrite=true`. Not the audit-#18
# pin itself (this verb didn't touch the drive-lookup
# bug) but a quick "the setup is sound" gate before
# Step 5.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{wopi_file_id}}?access_token={{wopi_edit_token}}
HTTP 200
[Asserts]
jsonpath "$.UserCanWrite" == true
# ─────────────────────────────────────────────────────────────
# Step 5 — PutFile with fresh content → 200.
#
# PRE-FIX (before #18 close): this 500'd. The handler
# resolved drive_id via find_default_for_user(admin),
# got admin's personal drive, then
# `update_file_streaming_with_perms(path, personal_drive_id)`
# did a parent-folder-by-path lookup scoped to the
# personal drive — nothing at the shared-drive path
# existed there → error → 500 wrapper.
#
# POST-FIX: drive_id resolves from the file's own
# parent folder → shared drive → write lands in the
# correct drive.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/wopi/files/{{wopi_file_id}}/contents?access_token={{wopi_edit_token}}
Content-Type: application/octet-stream
```
audit-#18 shared-drive WOPI PutFile canary
```
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 6 — Round-trip proof: GetFile from the same token returns
# the NEW content, and it's coming from the shared
# drive (the only place `wopi_file_id` exists).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{wopi_file_id}}/contents?access_token={{wopi_edit_token}}
HTTP 200
[Asserts]
body contains "audit-#18 shared-drive WOPI PutFile canary"
# ─────────────────────────────────────────────────────────────
# Cleanup — delete the file, then delete the shared drive
# (D3b: empty-drive precondition holds since the file is gone).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{wopi_file_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/drives/{{wopi_drive_id}}
Authorization: Bearer {{admin_token}}
HTTP 204