Skip to main content

cloudillo_core/
share_access.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Who may read and who may change a file's share set.
5//!
6//! Lives in `cloudillo-core` because it gates two crates: the share-entry endpoints in
7//! `cloudillo-file` and the ref (share-link) endpoints in `cloudillo-ref`. A `refId` is a bearer
8//! credential, so minting, listing or revoking one is share management and must pass the same gate
9//! as `POST /api/files/{id}/shares`.
10//!
11//! Every `require_*` entry point refuses any scoped token, share-link delegation *or* API-key
12//! capability scope alike: a delegated link must never widen or mutate the grant that admitted it
13//! (confused-deputy), and share management is never delegable.
14//!
15//! # Handler ordering convention
16//!
17//! Every share-entry and ref handler runs its checks in this order, so both crates answer the same
18//! request shape the same way:
19//!
20//! 1. The caller-shape check (`reject_scoped`, or the scope refusal inside
21//!    [`require_unscoped_file_access`]) before anything, body validation included: a scoped caller
22//!    must not learn even whether their request was well formed.
23//! 2. Resource authorization as soon as the resource id is known.
24//! 3. Body validation last — except where a gate needs a parsed value, such as
25//!    [`ensure_grant_within`] needing the validated permission char.
26
27use crate::prelude::*;
28use cloudillo_types::auth_adapter::AuthCtx;
29use cloudillo_types::types::AccessLevel;
30
31use crate::file_access::{self, FileAccessCtx, FileAccessResult};
32
33/// Refuse any scoped token, then resolve the caller's unscoped access to `file_id`.
34///
35/// The weakest of the share gates: it confers no standing, only "this caller can reach the row
36/// under their own identity". Use it where [`require_share_reader`]'s Write floor would be too
37/// strict; for anything conferring standing use [`share_standing`] or the `require_*` wrappers.
38pub async fn require_unscoped_file_access(
39	app: &App,
40	tn_id: TnId,
41	file_id: &str,
42	auth: &AuthCtx,
43	tenant_id_tag: &str,
44) -> ClResult<FileAccessResult> {
45	if auth.scope.is_some() {
46		warn!("Scoped token attempted to access share entries");
47		return Err(Error::PermissionDenied);
48	}
49
50	let ctx = FileAccessCtx { user_id_tag: &auth.id_tag, tenant_id_tag, user_roles: &auth.roles };
51	// Scope `None` — scoped callers were rejected above.
52	match file_access::check_file_access_with_scope(app, tn_id, file_id, &ctx, None, None).await {
53		Err(file_access::FileAccessError::NotFound) => Err(Error::NotFound),
54		Err(file_access::FileAccessError::AccessDenied) => Err(Error::PermissionDenied),
55		Err(file_access::FileAccessError::InternalError(msg)) => Err(Error::Internal(msg)),
56		Ok(access) => Ok(access),
57	}
58}
59
60/// Pure share-management decision.
61///
62/// Requires access to the file itself, plus standing: a community leader whose tenant owns the row,
63/// an explicit `'A'` share grant, the file owner, or — **only for tenant-owned files** — the member
64/// who created the row.
65///
66/// The creator rule exists because a local file leaves `files.owner_tag` NULL and the meta adapter
67/// back-fills the *tenant* profile as owner (`build_owner_profile`); without it, on a community
68/// tenant even the file's creator fails the owner test and only `leader` could manage shares. The
69/// `tenant_owned` guard keeps a locally-placed copy of a foreign file (Pin/Place row, `owner_tag` =
70/// foreign owner) out of the placer's reach.
71///
72/// `leader_over_tenant_row` means "holds `leader` **and** the row is tenant-owned": leadership is
73/// authority over the tenant's own content, not over a foreign owner's file that merely happens to
74/// be placed here. Same boundary `file_access::role_access_level` draws.
75fn is_share_manager(
76	access: AccessLevel,
77	subject: &str,
78	tenant_id_tag: &str,
79	owner_id_tag: Option<&str>,
80	creator_id_tag: Option<&str>,
81	leader_over_tenant_row: bool,
82) -> bool {
83	// Defence in depth: `require_unscoped_file_access` already rejected anyone who cannot reach it.
84	if access == AccessLevel::None {
85		return false;
86	}
87	// `can_manage_shares()` covers the `'A'` grant — `from_perm_char` maps it straight to `Admin`,
88	// as does owner/leader over a tenant-owned row. The explicit tests are for foreign-owned rows,
89	// where neither fires.
90	if leader_over_tenant_row || access.can_manage_shares() || owner_id_tag == Some(subject) {
91		return true;
92	}
93	let tenant_owned = owner_id_tag == Some(tenant_id_tag);
94	tenant_owned && creator_id_tag == Some(subject)
95}
96
97/// Pure share-*listing* decision: any unscoped caller with Write-or-better access may enumerate a
98/// file's share entries.
99///
100/// Not the whole reader test — a manager outranks a reader even at `AccessLevel::Read` (the
101/// creator rule above). [`classify_standing`] composes the two.
102fn is_share_reader(access: AccessLevel) -> bool {
103	access.can_write()
104}
105
106/// A caller's standing over one file's share set. Ordered: `Manager` implies `Reader`.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
108pub enum ShareStanding {
109	None,
110	Reader,
111	Manager,
112}
113
114/// Pure classifier behind [`share_standing`].
115fn classify_standing(
116	access: AccessLevel,
117	subject: &str,
118	tenant_id_tag: &str,
119	owner_id_tag: Option<&str>,
120	creator_id_tag: Option<&str>,
121	leader_over_tenant_row: bool,
122) -> ShareStanding {
123	if is_share_manager(
124		access,
125		subject,
126		tenant_id_tag,
127		owner_id_tag,
128		creator_id_tag,
129		leader_over_tenant_row,
130	) {
131		ShareStanding::Manager
132	} else if is_share_reader(access) {
133		ShareStanding::Reader
134	} else {
135		ShareStanding::None
136	}
137}
138
139/// Pure grant-ceiling rule behind [`share_standing`].
140///
141/// Everyone is capped at what they hold — already `Admin` for an owner, a leader over a
142/// tenant-owned row, or an explicit `'A'` grantee. The cap is what stops the `Read`-level creator
143/// of a tenant-owned file (a share manager by the creator rule) from minting a `write` grant and
144/// redeeming it.
145///
146/// `is_owner` still has to be named: `file_access::role_access_level` resolves roles only for
147/// tenant-owned files, so over a *foreign-owned* pinned row `access` alone would under-grant the
148/// owner. `leader_over_tenant_row` means the same as in [`is_share_manager`], so a leader never
149/// gains a ceiling over foreign content.
150fn grant_ceiling(access: AccessLevel, is_owner: bool, leader_over_tenant_row: bool) -> AccessLevel {
151	if leader_over_tenant_row || is_owner { AccessLevel::Admin } else { access }
152}
153
154/// A caller's resolved authority over one file's share set.
155pub struct ShareAuthority {
156	/// The caller's own access, so callers needing the file view do not re-fetch it.
157	pub access: FileAccessResult,
158	pub standing: ShareStanding,
159	/// The highest level this caller may hand out: their own `access_level`, already `Admin` for
160	/// ownership-derived standing.
161	pub grant_ceiling: AccessLevel,
162}
163
164/// Resolve the caller's standing over `file_id`'s share set in one pass.
165///
166/// Rejects scoped (share-link) callers, resolves file access, then classifies.
167pub async fn share_standing(
168	app: &App,
169	tn_id: TnId,
170	file_id: &str,
171	auth: &AuthCtx,
172	tenant_id_tag: &str,
173) -> ClResult<ShareAuthority> {
174	let access = require_unscoped_file_access(app, tn_id, file_id, auth, tenant_id_tag).await?;
175
176	let owner_id_tag =
177		effective_owner(access.file_view.owner.as_ref().map(|p| p.id_tag.as_ref()), tenant_id_tag);
178	let creator_id_tag =
179		non_empty_id_tag(access.file_view.creator.as_ref().map(|p| p.id_tag.as_ref()));
180	// Leadership is authority over the tenant's own content only: a Pin/Place row carries the
181	// foreign owner in `owner_tag`, and minting grants or links on it is the owner's call.
182	let leader_over_tenant_row =
183		crate::roles::is_leader(&auth.roles) && owner_id_tag == tenant_id_tag;
184
185	// No extra query needed: an explicit `'A'` entry (direct or folder-inherited), ownership, and
186	// the leader role on a tenant-owned file all already reached `access_level` as `Admin`.
187	let standing = classify_standing(
188		access.access_level,
189		&auth.id_tag,
190		tenant_id_tag,
191		Some(owner_id_tag),
192		creator_id_tag,
193		leader_over_tenant_row,
194	);
195
196	let ceiling =
197		grant_ceiling(access.access_level, owner_id_tag == &*auth.id_tag, leader_over_tenant_row);
198
199	Ok(ShareAuthority { access, standing, grant_ceiling: ceiling })
200}
201
202/// Resolve share standing for a server-side actor named only by `id_tag` — no token, hence no
203/// [`AuthCtx`] to hand in. Used by the FSHR native hook, where the actor is the action's issuer and
204/// the write it guards (`create_share_entry`) is the same one `POST /api/files/{id}/shares` makes.
205///
206/// Roles are resolved the way `cloudillo-auth`'s access-token path does: the tenant account is
207/// implicitly `leader`, everyone else gets their profile row expanded through
208/// [`crate::roles::expand_roles_preserving_extras`]. A *missing* profile yields no roles, which only
209/// ever denies; an *unreadable* one propagates, so a transient database failure surfaces as an
210/// internal error rather than a misleading `PermissionDenied`. `scope` is always `None` — a hook is
211/// never a delegated caller.
212pub async fn share_standing_for_actor(
213	app: &App,
214	tn_id: TnId,
215	file_id: &str,
216	actor_id_tag: &str,
217	tenant_id_tag: &str,
218) -> ClResult<ShareAuthority> {
219	let roles: Box<[Box<str>]> = if actor_id_tag == tenant_id_tag {
220		crate::roles::parse_roles(&crate::roles::expand_roles_preserving_extras(&["leader".into()]))
221	} else {
222		match app.meta_adapter.read_profile_roles(tn_id, actor_id_tag).await? {
223			Some(highest) => {
224				crate::roles::parse_roles(&crate::roles::expand_roles_preserving_extras(&highest))
225			}
226			None => Box::new([]),
227		}
228	};
229
230	let auth = AuthCtx { tn_id, id_tag: actor_id_tag.into(), roles, scope: None, anonymous: false };
231	share_standing(app, tn_id, file_id, &auth, tenant_id_tag).await
232}
233
234/// A share manager may not hand out more access than [`ShareAuthority::grant_ceiling`] allows.
235///
236/// Manager standing is ownership-derived, not Write-derived (a `Read`-level creator of a
237/// tenant-owned file qualifies), and `file_access::get_access_level_with_scope` returns a
238/// share-link scope's level uncapped by the holder's own ACL — so without this a `Read` manager
239/// could mint a `write` link and redeem it to escalate themselves.
240pub fn ensure_grant_within(
241	granted: AccessLevel,
242	own: AccessLevel,
243	subject: &str,
244	file_id: &str,
245) -> ClResult<()> {
246	if granted <= own {
247		return Ok(());
248	}
249	warn!(
250		subject = %subject,
251		file_id = %file_id,
252		granted = %granted.as_str(),
253		own = %own.as_str(),
254		"Share grant denied - a manager may not hand out more access than they hold"
255	);
256	Err(Error::PermissionDenied)
257}
258
259/// Enforce a minimum standing, with the denial log every call site wants.
260///
261/// Shared by the `require_*` wrappers and by callers that need the [`ShareStanding`] itself (ref
262/// listing uses it to decide redaction), so there is one deny path rather than several.
263pub fn ensure_standing(
264	standing: ShareStanding,
265	min: ShareStanding,
266	subject: &str,
267	file_id: &str,
268) -> ClResult<()> {
269	if standing >= min {
270		return Ok(());
271	}
272	let reason = match min {
273		ShareStanding::Manager => "Share management denied - owner/creator/leader/admin required",
274		ShareStanding::Reader => "Share listing denied - write access or share management required",
275		// Unreachable (`standing >= None` always returns above), but given its own arm so a future
276		// variant cannot inherit the listing message by accident.
277		ShareStanding::None => "Share access denied - no minimum standing was required",
278	};
279	warn!(subject = %subject, file_id = %file_id, "{}", reason);
280	Err(Error::PermissionDenied)
281}
282
283/// Authorize share *management* (create/update/delete share entries, mint/revoke share links).
284///
285/// An ownership/admin operation, strictly stronger than plain Write access — see
286/// [`is_share_manager`] for who qualifies. Plain FSHR-`W` grantees and scoped share-link tokens are
287/// excluded, or a delegated link or mere write grant could re-share, grant admin, or emit FSHR to
288/// arbitrary users. They may only *list* shares, via [`require_share_reader`].
289pub async fn require_share_manager(
290	app: &App,
291	tn_id: TnId,
292	file_id: &str,
293	auth: &AuthCtx,
294	tenant_id_tag: &str,
295) -> ClResult<ShareAuthority> {
296	let authority = share_standing(app, tn_id, file_id, auth, tenant_id_tag).await?;
297	ensure_standing(authority.standing, ShareStanding::Manager, &auth.id_tag, file_id)?;
298	Ok(authority)
299}
300
301/// Authorize *listing* a file's share entries or share links.
302///
303/// Weaker than [`require_share_manager`]: any Write-access caller — including a plain FSHR-`W`
304/// grantee — may see who the file is shared with, since enumeration is not part of the re-share
305/// escalation that gate defends against. Scoped tokens are still rejected. Managers always pass,
306/// even at `AccessLevel::Read` — a creator who may mint and revoke links must be able to list them.
307pub async fn require_share_reader(
308	app: &App,
309	tn_id: TnId,
310	file_id: &str,
311	auth: &AuthCtx,
312	tenant_id_tag: &str,
313) -> ClResult<ShareAuthority> {
314	let authority = share_standing(app, tn_id, file_id, auth, tenant_id_tag).await?;
315	ensure_standing(authority.standing, ShareStanding::Reader, &auth.id_tag, file_id)?;
316	Ok(authority)
317}
318
319/// Normalize a present-but-blank id_tag into `None`.
320fn non_empty_id_tag(id_tag: Option<&str>) -> Option<&str> {
321	id_tag.filter(|s| !s.is_empty())
322}
323
324/// A row's effective owner: mirrors `file_access::check_file_access_with_scope`, where a missing or
325/// blank `owner` means the tenant owns it. The two must agree — without the fallback,
326/// `tenant_owned` and `leader_over_tenant_row` read false wherever the meta adapter does not
327/// back-fill an owner, and the creator rule silently stops applying.
328fn effective_owner<'a>(owner: Option<&'a str>, tenant_id_tag: &'a str) -> &'a str {
329	non_empty_id_tag(owner).unwrap_or(tenant_id_tag)
330}
331
332#[cfg(test)]
333mod tests {
334	use super::*;
335
336	const TENANT: &str = "community.example.com";
337	const MEMBER: &str = "alice.example.com";
338	const OTHER: &str = "bob.example.com";
339
340	const W: AccessLevel = AccessLevel::Write;
341	const A: AccessLevel = AccessLevel::Admin;
342
343	#[test]
344	fn personal_tenant_owner_manages_shares() {
345		// Personal tenant: owner back-fills to the tenant profile, which *is* the caller.
346		assert!(is_share_manager(W, TENANT, TENANT, Some(TENANT), Some(TENANT), false));
347	}
348
349	#[test]
350	fn creator_of_tenant_owned_file_manages_shares() {
351		// On a community tenant the owner is the tenant, so the member who created the row must
352		// pass on the creator rule.
353		assert!(is_share_manager(W, MEMBER, TENANT, Some(TENANT), Some(MEMBER), false));
354	}
355
356	#[test]
357	fn non_creator_member_cannot_manage_shares() {
358		assert!(!is_share_manager(W, MEMBER, TENANT, Some(TENANT), Some(OTHER), false));
359	}
360
361	#[test]
362	fn leader_manages_shares() {
363		assert!(is_share_manager(W, MEMBER, TENANT, Some(TENANT), Some(OTHER), true));
364	}
365
366	#[test]
367	fn leader_does_not_reach_a_foreign_owned_row() {
368		// Pin/Place row: `owner_tag` holds the foreign owner, so `share_standing` passes
369		// `leader_over_tenant_row = false` and the leader is judged on their own access alone.
370		for access in [AccessLevel::Read, W] {
371			assert!(!is_share_manager(access, MEMBER, TENANT, Some(OTHER), Some(OTHER), false));
372			// ...and no lifted ceiling either: they may hand out at most what they hold.
373			assert_eq!(grant_ceiling(access, false, false), access);
374		}
375		// A leader who nonetheless holds an explicit `'A'` grant on the foreign row still manages
376		// it — that authority comes from the owner's own grant, not from leadership.
377		assert!(is_share_manager(A, MEMBER, TENANT, Some(OTHER), Some(OTHER), false));
378	}
379
380	#[test]
381	fn leader_over_a_tenant_owned_row_keeps_manager_standing_and_an_admin_ceiling() {
382		// Regression guard for the foreign-owned narrowing above: over the tenant's own content
383		// leadership is unchanged.
384		assert_eq!(
385			classify_standing(AccessLevel::Read, MEMBER, TENANT, Some(TENANT), Some(OTHER), true),
386			ShareStanding::Manager
387		);
388		assert_eq!(grant_ceiling(AccessLevel::Read, false, true), AccessLevel::Admin);
389	}
390
391	#[test]
392	fn creator_of_placed_foreign_file_cannot_manage_shares() {
393		// Pin/Place row: `owner_tag` holds the foreign owner, so the local
394		// placer (recorded as creator) must not gain share management.
395		assert!(!is_share_manager(W, MEMBER, TENANT, Some(OTHER), Some(MEMBER), false));
396	}
397
398	#[test]
399	fn explicit_admin_grant_manages_shares() {
400		// Same caller, at Write and at Admin: the `'A'` share entry resolves to `Admin` through
401		// `AccessLevel::from_perm_char`, so the access level alone carries the grant.
402		assert!(!is_share_manager(W, OTHER, TENANT, Some(TENANT), Some(MEMBER), false));
403		assert!(is_share_manager(A, OTHER, TENANT, Some(TENANT), Some(MEMBER), false));
404	}
405
406	#[test]
407	fn plain_write_grantee_cannot_manage_shares() {
408		// The FSHR-`W` grantee: no owner/creator/leader/admin standing, so it fails
409		// regardless of access level.
410		assert!(!is_share_manager(W, OTHER, TENANT, Some(MEMBER), Some(MEMBER), false));
411	}
412
413	#[test]
414	fn missing_owner_and_creator_deny() {
415		assert!(!is_share_manager(W, MEMBER, TENANT, None, None, false));
416	}
417
418	#[test]
419	fn no_access_denies_before_any_standing_rule() {
420		// The `AccessLevel::None` short-circuit runs first, so even leadership over a tenant-owned
421		// row confers nothing on a caller who cannot reach the file at all.
422		assert!(!is_share_manager(
423			AccessLevel::None,
424			MEMBER,
425			TENANT,
426			Some(TENANT),
427			Some(MEMBER),
428			true
429		));
430	}
431
432	#[test]
433	fn read_access_creator_is_still_a_share_manager() {
434		// Manager standing is ownership-derived, not Write-derived: read access is enough once the
435		// caller created the tenant-owned file.
436		assert!(is_share_manager(
437			AccessLevel::Read,
438			MEMBER,
439			TENANT,
440			Some(TENANT),
441			Some(MEMBER),
442			false
443		));
444	}
445
446	#[test]
447	fn manager_standing_implies_reader() {
448		// Every combination `is_share_manager` accepts must reach at least Reader — including the
449		// `Read`-level creator, who fails `is_share_reader` and is admitted by the ordering alone.
450		for (access, subject, owner, creator, leader) in [
451			(W, TENANT, Some(TENANT), Some(TENANT), false),
452			(W, MEMBER, Some(TENANT), Some(MEMBER), false),
453			(W, MEMBER, Some(TENANT), Some(OTHER), true),
454			(A, OTHER, Some(TENANT), Some(MEMBER), false),
455			(AccessLevel::Read, MEMBER, Some(TENANT), Some(MEMBER), false),
456		] {
457			assert!(is_share_manager(access, subject, TENANT, owner, creator, leader));
458			let standing = classify_standing(access, subject, TENANT, owner, creator, leader);
459			assert_eq!(standing, ShareStanding::Manager);
460			assert!(standing >= ShareStanding::Reader);
461		}
462
463		// A plain FSHR-`W` grantee reads the share set but does not manage it.
464		let grantee = classify_standing(W, OTHER, TENANT, Some(MEMBER), Some(MEMBER), false);
465		assert_eq!(grantee, ShareStanding::Reader);
466
467		// Read access with no ownership standing reaches neither.
468		let outsider =
469			classify_standing(AccessLevel::Read, OTHER, TENANT, Some(MEMBER), Some(MEMBER), false);
470		assert_eq!(outsider, ShareStanding::None);
471	}
472
473	#[test]
474	fn admin_access_alone_confers_manager_standing_and_an_admin_ceiling() {
475		// The `'A'` grantee over a *foreign-owned* file: not owner, not creator, not leader, so
476		// the resolved access level is the whole story.
477		let standing = classify_standing(A, OTHER, TENANT, Some(MEMBER), Some(MEMBER), false);
478		assert_eq!(standing, ShareStanding::Manager);
479		// ...and they may re-share up to admin, because their own level already is admin.
480		assert_eq!(grant_ceiling(A, false, false), AccessLevel::Admin);
481	}
482
483	#[test]
484	fn share_reader_needs_write_access() {
485		assert!(is_share_reader(AccessLevel::Write));
486		assert!(is_share_reader(AccessLevel::Admin));
487		// Read access is not enough to enumerate the share set.
488		assert!(!is_share_reader(AccessLevel::Read));
489		assert!(!is_share_reader(AccessLevel::Comment));
490	}
491
492	#[test]
493	fn ensure_standing_enforces_the_minimum() {
494		let ok = |standing, min| ensure_standing(standing, min, MEMBER, "f1~test").is_ok();
495
496		// Manager outranks Reader, so it satisfies either minimum.
497		assert!(ok(ShareStanding::Manager, ShareStanding::Reader));
498		assert!(ok(ShareStanding::Manager, ShareStanding::Manager));
499		// A plain reader may list but not manage.
500		assert!(ok(ShareStanding::Reader, ShareStanding::Reader));
501		assert!(!ok(ShareStanding::Reader, ShareStanding::Manager));
502		// No standing satisfies nothing.
503		assert!(!ok(ShareStanding::None, ShareStanding::Reader));
504		assert!(!ok(ShareStanding::None, ShareStanding::Manager));
505
506		assert!(matches!(
507			ensure_standing(ShareStanding::None, ShareStanding::Reader, MEMBER, "f1~test"),
508			Err(Error::PermissionDenied)
509		));
510	}
511
512	#[test]
513	fn a_manager_cannot_grant_beyond_their_own_access() {
514		let ok = |granted, own| ensure_grant_within(granted, own, MEMBER, "f1~test").is_ok();
515
516		// The escalation this closes: the `Read`-level creator of a tenant-owned file is a share
517		// manager, so without the cap they could mint a `write` link and redeem it themselves.
518		assert!(!ok(AccessLevel::Write, AccessLevel::Read));
519		assert!(!ok(AccessLevel::Comment, AccessLevel::Read));
520		// Handing out what they hold, or less, is fine.
521		assert!(ok(AccessLevel::Read, AccessLevel::Read));
522		assert!(ok(W, W));
523		assert!(ok(AccessLevel::Comment, W));
524		assert!(ok(AccessLevel::Read, W));
525		// Admin outranks Write, so a Write-ceiling manager may not mint an admin-level grant.
526		assert_eq!(AccessLevel::from_perm_char('A'), AccessLevel::Admin);
527		assert!(!ok(AccessLevel::from_perm_char('A'), W));
528		assert!(ok(AccessLevel::from_perm_char('A'), AccessLevel::Admin));
529
530		assert!(matches!(
531			ensure_grant_within(W, AccessLevel::Read, MEMBER, "f1~test"),
532			Err(Error::PermissionDenied)
533		));
534	}
535
536	#[test]
537	fn grant_ceiling_is_admin_only_for_ownership_derived_standing() {
538		// Ownership is named explicitly, so an owner reading their own foreign-tenant-hosted row —
539		// where `role_access_level` never lifts `access` to Admin — still gets a full ceiling.
540		assert_eq!(grant_ceiling(W, true, false), AccessLevel::Admin);
541		// Leadership lifts the ceiling only over a tenant-owned row; the caller resolves that
542		// conjunction (see `leader_does_not_reach_a_foreign_owned_row`).
543		assert_eq!(grant_ceiling(W, false, true), AccessLevel::Admin);
544		// An explicit `'A'` grantee needs no special case: their own level already is Admin.
545		assert_eq!(grant_ceiling(A, false, false), AccessLevel::Admin);
546
547		// The creator rule confers management but no extra reach: the `Read`-level creator of a
548		// tenant-owned file is capped at Read, so they cannot mint the `write` link they would
549		// redeem to escalate themselves.
550		assert_eq!(grant_ceiling(AccessLevel::Read, false, false), AccessLevel::Read);
551		assert_eq!(grant_ceiling(W, false, false), W);
552
553		// End to end: that creator may hand out Read and nothing more.
554		let creator = grant_ceiling(AccessLevel::Read, false, false);
555		assert!(ensure_grant_within(AccessLevel::Read, creator, MEMBER, "f1~test").is_ok());
556		assert!(ensure_grant_within(W, creator, MEMBER, "f1~test").is_err());
557		let owner = grant_ceiling(W, true, false);
558		assert!(
559			ensure_grant_within(AccessLevel::from_perm_char('A'), owner, MEMBER, "f1~test").is_ok()
560		);
561	}
562
563	#[test]
564	fn non_empty_id_tag_normalizes_blank() {
565		assert_eq!(non_empty_id_tag(Some("")), None);
566		assert_eq!(non_empty_id_tag(Some(MEMBER)), Some(MEMBER));
567		assert_eq!(non_empty_id_tag(None), None);
568	}
569
570	#[test]
571	fn a_row_with_no_explicit_owner_belongs_to_the_tenant() {
572		// `file_access::check_file_access_with_scope` resolves a missing owner to the tenant, so
573		// this must too — otherwise `tenant_owned` reads false and the creator rule silently stops
574		// applying to exactly the rows it exists for.
575		assert_eq!(effective_owner(None, TENANT), TENANT);
576		assert_eq!(effective_owner(Some(""), TENANT), TENANT);
577		assert_eq!(effective_owner(Some(MEMBER), TENANT), MEMBER);
578
579		// End to end over the pure half: the creator of such a row still manages its shares...
580		let owner = effective_owner(None, TENANT);
581		assert!(is_share_manager(W, MEMBER, TENANT, Some(owner), Some(MEMBER), false));
582		// ...and a leader still reaches it (the caller resolves the same conjunction).
583		let leader_over_tenant_row = owner == TENANT;
584		assert!(is_share_manager(
585			AccessLevel::Read,
586			OTHER,
587			TENANT,
588			Some(owner),
589			Some(MEMBER),
590			leader_over_tenant_row
591		));
592		// An unresolved owner denies, which is why the fallback has to run before this point.
593		assert!(!is_share_manager(W, MEMBER, TENANT, None, Some(MEMBER), false));
594	}
595}
596
597// vim: ts=4