Merge pull request #610 from EdouardVanbelle/security/grants2
This commit is contained in:
@@ -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
@@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user