Skip to content

Commit ec270fe

Browse files
Scope link-sharing settings to the window's team
Replace the ambient `is_anyone_with_link_sharing_enabled` / `is_direct_link_sharing_enabled` reads with getters that take a `TeamScope`, resolving each window's own team policy instead of the current workspace's arbitrarily-chosen `teams[0]` effective settings. The sharing dialog resolves a `TeamContext` from its own view handle, freshly on every read, and re-checks the policy before sending invitations and before applying link permissions, so a dialog left open while its window moves to a restricting team cannot act on the policy it opened with.
1 parent e2a0802 commit ec270fe

5 files changed

Lines changed: 404 additions & 30 deletions

File tree

app/src/drive/sharing/dialog/mod.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ use crate::word_block_editor::{
6060
WordBlockStyles,
6161
};
6262
use crate::workspace::{ToastStack, WorkspaceAction};
63-
use crate::workspaces::user_workspaces::UserWorkspaces;
63+
use crate::workspaces::user_workspaces::{TeamContext, UserWorkspaces};
6464
use crate::{TelemetryEvent, send_telemetry_from_ctx};
6565

6666
mod inheritance;
@@ -475,12 +475,29 @@ impl SharingDialog {
475475
self.target.is_some() && self.access_level(app).can_edit_access()
476476
}
477477

478+
/// The team this dialog's window is sharing as, resolved afresh on every read so a window
479+
/// that moves to another team is never governed by the team it opened with.
480+
///
481+
/// `None` when the dialog's window cannot be located, which for a live dialog means its
482+
/// window is gone: `RootView::new` registers every window as it is created.
483+
fn team_scope<'a>(&self, app: &'a AppContext) -> Option<TeamContext<'a>> {
484+
UserWorkspaces::as_ref(app).team_context(&self.self_handle, app)
485+
}
486+
487+
/// Whether this window's team permits sharing the target with anyone who holds its link.
488+
/// A window whose team cannot be resolved denies: offering a sharing channel whose
489+
/// governing policy is unknown is the unsafe direction to guess in.
478490
fn can_anyone_with_link_share(&self, app: &AppContext) -> bool {
479-
UserWorkspaces::as_ref(app).is_anyone_with_link_sharing_enabled()
491+
self.team_scope(app).is_some_and(|scope| {
492+
UserWorkspaces::as_ref(app).is_anyone_with_link_sharing_enabled(&scope)
493+
})
480494
}
481495

496+
/// Whether this window's team permits sharing the target directly with named people. See
497+
/// [`Self::can_anyone_with_link_share`] for why an unresolvable team denies.
482498
fn can_direct_link_share(&self, app: &AppContext) -> bool {
483-
UserWorkspaces::as_ref(app).is_direct_link_sharing_enabled()
499+
self.team_scope(app)
500+
.is_some_and(|scope| UserWorkspaces::as_ref(app).is_direct_link_sharing_enabled(&scope))
484501
}
485502

486503
/// The editability state of the object.
@@ -1615,6 +1632,13 @@ impl SharingDialog {
16151632

16161633
/// Send all pending email invitations.
16171634
fn send_invitations(&mut self, ctx: &mut ViewContext<Self>) {
1635+
// Re-read the policy instead of trusting the render that put the form on screen: an
1636+
// open dialog whose window moved to a team that forbids direct sharing must not send
1637+
// the invitations that team now disallows.
1638+
if !self.can_direct_link_share(ctx) {
1639+
return;
1640+
}
1641+
16181642
let form_state = self.invite_form_state(ctx);
16191643
if !form_state.is_valid() {
16201644
return;
@@ -2930,6 +2954,13 @@ impl TypedActionView for SharingDialog {
29302954
}
29312955
SharingDialogAction::SetLinkPermissions(access_level) => {
29322956
self.set_open_menu(OpenMenuState::None, ctx);
2957+
// The menu's items were built when it opened. Re-read the policy so a window
2958+
// that has since moved to a team forbidding link sharing cannot act on the
2959+
// permission it was offered under the old one.
2960+
if !self.can_anyone_with_link_share(ctx) {
2961+
ctx.notify();
2962+
return;
2963+
}
29332964
if let Some(ShareableObject::WarpDriveObject(id)) = self.target.as_ref() {
29342965
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
29352966
update_manager.set_object_link_permissions(*id, *access_level, ctx);

app/src/drive/sharing/dialog/mod_tests.rs

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ use crate::terminal::TerminalView;
88
use crate::terminal::shared_session::manager::Manager;
99
use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus};
1010
use crate::test_util::add_window_with_terminal;
11-
use crate::test_util::terminal::initialize_app_for_terminal_view;
11+
use crate::test_util::terminal::{
12+
add_window_with_id_and_terminal, initialize_app_for_terminal_view,
13+
};
14+
use crate::workspaces::team::{Team, TeamVisibility};
15+
use crate::workspaces::user_workspaces::UserWorkspaces;
16+
use crate::workspaces::workspace::{
17+
EnforceableSetting, TeamLinkSharingSettings, TeamSettings, Workspace,
18+
};
1219

1320
fn set_shared_session_status(
1421
terminal: &ViewHandle<TerminalView>,
@@ -157,3 +164,170 @@ fn session_qr_code_requires_status_eligible_matching_session_id() {
157164
assert_session_link_state(&terminal, &dialog, Some(second_session_id), &app);
158165
});
159166
}
167+
168+
/// A team whose admins either permit or forbid both link-sharing channels.
169+
fn team_with_link_sharing(uid: i64, name: &str, permitted: bool) -> Team {
170+
let permission = EnforceableSetting {
171+
value: permitted,
172+
is_enforced_by_workspace: false,
173+
};
174+
Team {
175+
uid: uid.into(),
176+
name: name.to_string(),
177+
color: None,
178+
invite_link: None,
179+
members: vec![],
180+
pending_email_invites: vec![],
181+
invite_link_domain_restrictions: vec![],
182+
billing_metadata: Default::default(),
183+
stripe_customer_id: None,
184+
settings: TeamSettings {
185+
link_sharing: TeamLinkSharingSettings {
186+
anyone_with_link_sharing_enabled: permission.clone(),
187+
direct_link_sharing_enabled: permission,
188+
},
189+
..Default::default()
190+
},
191+
is_eligible_for_discovery: false,
192+
has_billing_history: false,
193+
visibility: TeamVisibility::Open,
194+
}
195+
}
196+
197+
/// Replaces the user's workspaces with one holding `teams` and selects it, the way a
198+
/// workspaces-metadata refresh does. Windows already assigned to a team that `teams` no longer
199+
/// contains reconcile onto the first remaining one.
200+
fn install_workspace_with_teams(app: &mut App, teams: Vec<Team>) {
201+
let workspace = Workspace {
202+
uid: "workspace_uid123456789".to_string().into(),
203+
name: "test".to_string(),
204+
stripe_customer_id: None,
205+
teams,
206+
billing_metadata: Default::default(),
207+
bonus_grants_purchased_this_month: Default::default(),
208+
billing_cycle_usage: None,
209+
has_billing_history: false,
210+
settings: Default::default(),
211+
invite_link_domain_restrictions: vec![],
212+
pending_email_invites: vec![],
213+
is_eligible_for_discovery: false,
214+
members: vec![],
215+
total_requests_used_since_last_refresh: 0,
216+
};
217+
let workspace_uid = workspace.uid;
218+
219+
let user_workspaces = UserWorkspaces::handle(&*app);
220+
user_workspaces.update(app, |user_workspaces, ctx| {
221+
user_workspaces.update_workspaces(vec![workspace], ctx);
222+
user_workspaces.set_current_workspace_uid(workspace_uid, ctx);
223+
});
224+
}
225+
226+
#[test]
227+
fn link_sharing_gates_resolve_each_windows_own_team() {
228+
App::test((), |mut app| async move {
229+
initialize_app_for_terminal_view(&mut app);
230+
231+
let (permitted_window, _permitted_terminal) =
232+
add_window_with_id_and_terminal(&mut app, None);
233+
let (forbidden_window, _forbidden_terminal) =
234+
add_window_with_id_and_terminal(&mut app, None);
235+
236+
let permitted_team = team_with_link_sharing(123, "permits-sharing", true);
237+
let forbidden_team = team_with_link_sharing(456, "forbids-sharing", false);
238+
install_workspace_with_teams(
239+
&mut app,
240+
vec![permitted_team.clone(), forbidden_team.clone()],
241+
);
242+
243+
let user_workspaces = UserWorkspaces::handle(&app);
244+
user_workspaces.update(&mut app, |user_workspaces, ctx| {
245+
user_workspaces.set_team_for_window(permitted_window, permitted_team.uid, ctx);
246+
user_workspaces.set_team_for_window(forbidden_window, forbidden_team.uid, ctx);
247+
});
248+
249+
let permitted_dialog =
250+
app.add_typed_action_view(permitted_window, |ctx| SharingDialog::new(None, ctx));
251+
let forbidden_dialog =
252+
app.add_typed_action_view(forbidden_window, |ctx| SharingDialog::new(None, ctx));
253+
254+
permitted_dialog.read(&app, |dialog, ctx| {
255+
assert!(dialog.can_anyone_with_link_share(ctx));
256+
assert!(dialog.can_direct_link_share(ctx));
257+
});
258+
forbidden_dialog.read(&app, |dialog, ctx| {
259+
assert!(
260+
!dialog.can_anyone_with_link_share(ctx),
261+
"a dialog in a window on a forbidding team must not inherit the other window's \
262+
permission"
263+
);
264+
assert!(!dialog.can_direct_link_share(ctx));
265+
});
266+
});
267+
}
268+
269+
/// The dialog re-reads these gates when it renders and again before it acts, in
270+
/// `send_invitations` and in the `SetLinkPermissions` handler. Resolving them from the window
271+
/// rather than caching them at open is what stops an already-open dialog from sharing under
272+
/// the policy it opened with after its window has moved to a team that forbids sharing.
273+
#[test]
274+
fn link_sharing_gates_follow_a_window_onto_its_new_team() {
275+
App::test((), |mut app| async move {
276+
initialize_app_for_terminal_view(&mut app);
277+
278+
let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None);
279+
280+
let permitted_team = team_with_link_sharing(123, "permits-sharing", true);
281+
let forbidden_team = team_with_link_sharing(456, "forbids-sharing", false);
282+
install_workspace_with_teams(
283+
&mut app,
284+
vec![permitted_team.clone(), forbidden_team.clone()],
285+
);
286+
287+
let user_workspaces = UserWorkspaces::handle(&app);
288+
user_workspaces.update(&mut app, |user_workspaces, ctx| {
289+
user_workspaces.set_team_for_window(window_id, permitted_team.uid, ctx);
290+
});
291+
292+
let dialog = app.add_typed_action_view(window_id, |ctx| SharingDialog::new(None, ctx));
293+
dialog.read(&app, |dialog, ctx| {
294+
assert!(dialog.can_anyone_with_link_share(ctx));
295+
assert!(dialog.can_direct_link_share(ctx));
296+
});
297+
298+
// The permitting team leaves the workspace, so the window reconciles onto the
299+
// forbidding one while the dialog is still open.
300+
install_workspace_with_teams(&mut app, vec![forbidden_team]);
301+
302+
dialog.read(&app, |dialog, ctx| {
303+
assert!(
304+
!dialog.can_anyone_with_link_share(ctx),
305+
"an open dialog must lose link sharing when its window moves to a team that \
306+
forbids it"
307+
);
308+
assert!(!dialog.can_direct_link_share(ctx));
309+
});
310+
});
311+
}
312+
313+
#[test]
314+
fn link_sharing_gates_deny_when_the_dialogs_window_has_no_team() {
315+
App::test((), |mut app| async move {
316+
initialize_app_for_terminal_view(&mut app);
317+
318+
let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None);
319+
install_workspace_with_teams(&mut app, vec![team_with_link_sharing(123, "team", true)]);
320+
321+
// Deliberately left unregistered: no window team can be resolved for this dialog.
322+
let dialog = app.add_typed_action_view(window_id, |ctx| SharingDialog::new(None, ctx));
323+
324+
dialog.read(&app, |dialog, ctx| {
325+
assert!(
326+
!dialog.can_anyone_with_link_share(ctx),
327+
"a dialog whose window team cannot be resolved must not offer a sharing channel \
328+
governed by an unknown policy"
329+
);
330+
assert!(!dialog.can_direct_link_share(ctx));
331+
});
332+
});
333+
}

app/src/workspaces/user_workspaces.rs

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ use crate::settings::{
4141
#[cfg(test)]
4242
use crate::workspaces::workspace::{AIAutonomyPolicy, WorkspaceMember, WorkspaceSettings};
4343
use crate::workspaces::workspace::{
44-
AiAutonomySettings, AiOverages, PurchaseAddOnCreditsPolicy, SandboxedAgentSettings,
45-
UsageBasedPricingSettings,
44+
AiAutonomySettings, AiOverages, LinkSharingSettings, PurchaseAddOnCreditsPolicy,
45+
SandboxedAgentSettings, UsageBasedPricingSettings,
4646
};
4747

4848
const STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX: &str = "/upgrade";
@@ -241,9 +241,6 @@ impl TeamContextForOperation {
241241
/// a [`TeamContextForOperation`]. A [`WeakViewHandle`] locates a window to read from; it is
242242
/// not evidence that the holder is running in that window, which is what minting operation
243243
/// scope requires.
244-
// Only tests construct one today; remove this once a Group 1 migration PR resolves one from a
245-
// real render.
246-
#[allow(dead_code)]
247244
pub(crate) struct TeamContext<'a> {
248245
team_uid: Option<&'a ServerId>,
249246
}
@@ -463,8 +460,6 @@ impl UserWorkspaces {
463460
}
464461

465462
/// Resolves `view`'s window team for one render. See [`TeamContext`].
466-
// Only tests call this today; remove once a Group 1 migration PR has a real call site.
467-
#[allow(dead_code)]
468463
pub(crate) fn team_context<'a, T: Entity>(
469464
&'a self,
470465
view: &WeakViewHandle<T>,
@@ -479,6 +474,19 @@ impl UserWorkspaces {
479474
Some(TeamContext { team_uid })
480475
}
481476

477+
/// Reads a `TeamScope`'s team, whether captured (a [`TeamContextForOperation`]) or freshly
478+
/// resolved (a [`TeamContext`]). Returns `None` once that team is gone from the current
479+
/// workspace, e.g. after the user leaves it, or when `scope` carries no team.
480+
///
481+
/// Private on purpose: a scope resolves to a *setting*, through one of the getters below,
482+
/// never to a `Team` a caller could carry somewhere the scope never reached. A call site
483+
/// that wants a `Team` from a scope wants a new getter instead.
484+
fn team_from_scope<S: TeamScope + ?Sized>(&self, scope: &S) -> Option<&Team> {
485+
scope
486+
.team_uid()
487+
.and_then(|team_uid| self.team_from_uid(team_uid))
488+
}
489+
482490
/// Returns the windows whose team assignment changed.
483491
#[must_use]
484492
fn reconcile_window_team_assignments(&mut self) -> Vec<WindowId> {
@@ -1922,26 +1930,45 @@ impl UserWorkspaces {
19221930
.unwrap_or_default()
19231931
}
19241932

1925-
pub fn is_anyone_with_link_sharing_enabled(&self) -> bool {
1926-
self.current_workspace()
1927-
.map(|workspace| {
1928-
workspace
1929-
.settings
1930-
.link_sharing_settings
1933+
/// The link-sharing policy that governs `scope`.
1934+
///
1935+
/// A scope that resolves to a team reads that team's effective policy, so two windows on
1936+
/// different teams disagree exactly as their admins configured them.
1937+
///
1938+
/// A scope with no team is not on a team, and no team's policy may stand in for that.
1939+
/// [`Self::current_workspace`]'s settings are only genuine workspace-level data while the
1940+
/// workspace has no teams; once it has any, the server resolved them for one arbitrarily
1941+
/// chosen team, so a teamless scope gets [`LinkSharingSettings::UNRESTRICTED`] instead.
1942+
fn link_sharing_settings(&self, scope: &impl TeamScope) -> LinkSharingSettings {
1943+
if let Some(team) = self.team_from_scope(scope) {
1944+
let link_sharing = &team.settings.link_sharing;
1945+
return LinkSharingSettings {
1946+
anyone_with_link_sharing_enabled: link_sharing
19311947
.anyone_with_link_sharing_enabled
1932-
})
1933-
.unwrap_or(true)
1934-
}
1948+
.value,
1949+
direct_link_sharing_enabled: link_sharing.direct_link_sharing_enabled.value,
1950+
};
1951+
}
1952+
1953+
if self.has_teams() {
1954+
return LinkSharingSettings::UNRESTRICTED;
1955+
}
19351956

1936-
pub fn is_direct_link_sharing_enabled(&self) -> bool {
19371957
self.current_workspace()
1938-
.map(|workspace| {
1939-
workspace
1940-
.settings
1941-
.link_sharing_settings
1942-
.direct_link_sharing_enabled
1943-
})
1944-
.unwrap_or(true)
1958+
.map(|workspace| workspace.settings.link_sharing_settings.clone())
1959+
.unwrap_or(LinkSharingSettings::UNRESTRICTED)
1960+
}
1961+
1962+
/// Whether `scope` may share an object with anyone who holds its link.
1963+
pub(crate) fn is_anyone_with_link_sharing_enabled(&self, scope: &impl TeamScope) -> bool {
1964+
self.link_sharing_settings(scope)
1965+
.anyone_with_link_sharing_enabled
1966+
}
1967+
1968+
/// Whether `scope` may share an object directly with named people.
1969+
pub(crate) fn is_direct_link_sharing_enabled(&self, scope: &impl TeamScope) -> bool {
1970+
self.link_sharing_settings(scope)
1971+
.direct_link_sharing_enabled
19451972
}
19461973

19471974
/// Whether invite links are enabled for the current workspace. This is a

0 commit comments

Comments
 (0)