[multi-team P1] Rework: require a team scope on the member BYO key/endpoint getters - #15446
[multi-team P1] Rework: require a team scope on the member BYO key/endpoint getters#15446warp-agent-staging[bot] wants to merge 1 commit into
Conversation
|
This PR was generated with Warp. Comment |
There was a problem hiding this comment.
Overview
One suggested test, offered rather than requested — the full review of this PR is still running and its findings will follow separately.
Concerns
- Nothing pins the departed-team case, and it is the most load-bearing consequence of this migration. A scope naming a team that has left the current workspace currently returns
falsefrom both allow-getters, so the restriction stands rather than resolving to another team'steam_byo. The tempting simplification of those getters isis_none_or, which silently inverts that into inheriting a stranger's policy, and no test fails today if someone makes that edit. Suggested test below, written against the current tip in this PR's own style using thetwo_teams_with_opposing_byo_policyfixture — it deliberately uses the captured operation scope rather than a window scope, becauseteam_context_for_windowresolves the team before building the scope and so cannot produce this state, which is also the honest reason the case exists at all.
/// A scope can outlive its team leaving the workspace. The restriction then stands: resolving
/// to some other team's `team_byo` is exactly what this migration exists to stop, so the
/// no-team-means-unrestricted branch must not swallow a team that merely cannot be read.
#[test]
fn member_byo_policy_denies_a_scope_naming_a_team_outside_the_workspace() {
let (team_a, _team_b) = two_teams_with_opposing_byo_policy();
let workspace = workspace_for_test(&team_a);
App::test((), |mut app| async move {
initialize_window_team_test_app(&mut app, vec![workspace]);
let departed_team_scope = TeamContextForOperation::new_for_test(9999.into());
app.read(|ctx| {
let user_workspaces = UserWorkspaces::as_ref(ctx);
assert!(user_workspaces.is_managed_byok_byoe_enabled());
assert!(
!user_workspaces.are_member_byo_keys_allowed_for_scope(&departed_team_scope),
"a team whose policy cannot be read must not inherit another team's"
);
assert!(
!user_workspaces.are_member_byo_endpoints_allowed_for_scope(&departed_team_scope),
"a team whose policy cannot be read must not inherit another team's"
);
});
})
}TeamContextForOperation is already in scope via use super::*, and new_for_test is #[cfg(test)] pub(crate) on master. It has not been executed — please run it rather than trusting it.
Verdict
Checks: not yet complete — full review in progress
Found: 1 suggestion so far; the rest to follow.
There was a problem hiding this comment.
Overview
Full review, following the suggested test posted earlier. Reviewed at 6781ff3; the branch has since advanced, so the first item below may already be resolved. The architecture is right and the entitlement/policy split is drawn correctly — is_byo_api_key_enabled and is_custom_inference_enabled stay workspace-level, is_managed_byok_byoe_enabled is a faithful extraction of master's inline billing read, and the two scoped getters preserve master's team_byo shape. Three things need fixing and three need a decision.
Concerns
- A new test in this PR fails:
apply_team_byo_policy_rebinds_after_the_window_changes_team(app/src/ai/agent/api_tests.rs:354) panics with Cannot get singleton model of typePrivacySettingsthat was never registered. It is the only new test callingupdate_workspaces, which reachesnotify_and_emit_teams_changed→PrivacySettings::handle(ctx);user_workspaces_tests.rs:146registers it,api_tests.rs:149does not. Fix is one line inregister_workspace_and_api_key_manager—app.add_singleton_model(crate::PrivacySettings::mock);(notecrate::PrivacySettings, the inner module is private). A commit titled "Register PrivacySettings in the BYO policy tests' app" has landed since this review ran, so this is likely already done. app/src/ai/blocklist/controller/response_stream.rs:538— the Grok OAuth token is re-injected after the strip, which is the one request path that bypasses the new enforcement.spawn_requestcomputesbyo_allowedfromis_byo_api_key_enabledalone — plan entitlement, no team policy — and writes the refreshed token back into the already-stripped params at:563-565.apply_team_byo_policyclearsgrok_oauth_access_tokenbut leavesapi_keysasSome(..)whenever Bedrock or GEAP credentials are attached, i.e. precisely the org-managed deployment where ateam_byorestriction is most likely set — so the member's Grok token goes out despite the admin restriction. Where no org credential is attached,api_keysisNoneand the write is skipped, so the hole closes by accident rather than by design.begin_expired_grok_refresh(byo_allowed, ..)also keeps refreshing a token the team disallows, against that module's own rule (crates/ai/src/grok_subscription/mod.rs:167-172). Suggested fix: record the decision on the params (member_byo_credentials_allowed: bool, set byapply_team_byo_policy) and AND it intobyo_allowedat:538— that closes the re-injection and the background refresh together, and it needs a test, since no current test reaches this path.user_workspaces.rs:485,:953,:968— the departed-team paragraph in both doc comments is false for every scope this PR produces.team_context_for_windowresolves the uid throughteam_from_uidbefore storing it, so a departed team collapses toteam_uid() == Noneand takes the permissive branch;team_contextdelegates to it, so the handle path is identical. TheSome(_)deny branch is reachable only fromTeamContextForOperation, which has no production call sites — which is exactly why the test suggested above uses it. Worse,reconcile_window_team_assignments(:507) reassigns such a window toteams[0], literally the "resolving to some other team'steam_byo" the doc says it refuses. That part is pre-existing master behaviour, not introduced here. The doc and the PR body should say the honest thing: a scope's team is already resolved, so a departed team is indistinguishable from teamless. Whether the deny should be made real is your call — it meansTeamContextcarrying the raw uid withteam_from_scopebecoming the resolution point, which changes P0's merged type and interacts with reconciliation'steams[0]fallback.app/src/ai/agent/api.rs:454— the selected model can dangle once endpoints are stripped.apply_team_byo_policyclearscustom_model_providersbut leavesparams.modelandcustom_model_routers, whose identifiers are custom-endpointconfig_keys. The picker's own gate is still ambient (deferred to P1b), so a user on a restrictive-team window can have a member custom model selected, send, and get an unexplained server-side failure. Fail-closed is the right direction; the absence of any explanation to the user is the question. Accept as an intermediate state and list it beside the TUI gap, or pull the picker gate into this slice?app/src/settings_view/warp_agent_page.rs:581— dragging the Settings tab between windows leaves widget state on the old team. Settings is a pane and can be dragged into a window on a different team, which fires neitherWindowTeamChangednorTeamsChanged. Render-time reads follow immediately, but the imperative state set inApiKeysWidget::newandsync_custom_endpoint_buttonsdoes not — so the section can render for the new window's team with the previous team's editors still enabled. The intended signal is P11'sSurfaceWindowChanged(#15441), not yet merged. Accept and document as a second known gap, or hold this PR for #15441?
Verdict
Checks: build pass, clippy pass (presubmit form), format pass, tests 87/88 — one of this PR's own new tests fails, CI partially green (Linux/wasm clippy and all release compiles pass; test jobs pending), visual proof missing (stack-wide limitation already recorded)
Found: 2 blocking, 3 important, 4 suggestions, 1 nit — the three decisions above are yours; the rest are with the author.
170ca77 to
4881d72
Compare
…indow's team Squashed for rebase onto master post-#15443. See PR description for the full account of the rework.
|
Reply after reworking this PR in place (now at
Full rework details are in the PR description. Responding as wilson: Open session · View in factory |
4881d72 to
ce03732
Compare


Description
Slice P1 of the multi-team window-scope stack (Linear
REV-2205; tech doc in #15347). Stops the member-BYO-credential settings from resolving againstcurrent_workspace().settings.*— an arbitrary team once the user belongs to more than one — and makes them resolve against the requesting window's own team instead.This is a rework of the PR in place, on request, after review found it left the ambient getters standing side-by-side with new
_for_scopevariants instead of replacing them. The getters themselves now take a required team scope; the old ambient callers were removed everywhere they could be, and renamed everywhere they still have to exist.The getters
UserWorkspaces::are_member_byo_keys_allowed/are_member_byo_endpoints_allowed/has_team_first_party_keynow takescope: &impl TeamScopedirectly (dropped the_for_scopesuffix — that name is retired, not kept as a synonym).RequestParams::new,CustomInferenceVisibility::compute, and the settings-page widgets all resolve a scope at the call site and pass it in — noOption<&dyn TeamScope>anywhere.RequestParams::newalready gained ascope: &impl TeamScopeparameter from [multi-team P3a-1] Scope the AI autonomy accessor to the window's team #15443 (the AI-autonomy slice, merged tomastermid-rework); this PR folds the BYO-credential gating into that same construction step (is_byo_enabled = is_byo_api_key_enabled(app) && are_member_byo_keys_allowed(scope), same for endpoints) rather than a separate post-construction "strip" pass. That removed theapply_team_byo_policymethod entirely along with the deferred-decision machinery it needed.team_byo_for_scope's no-team arm used to readcurrent_workspace().settings.team_byounconditionally. PerTeamScope's own documented contract (already onmaster), that read is only correct for a user who belongs to no team at all — for anyone with one or more teams it's one arbitrarily-chosen team's data, exactly what this project exists to stop. The arm now checkshas_teams(): a genuinely teamless user still gets the workspace fallback; a multi-team user's teamless scope (window with no team selected, or a windowUserWorkspaceswas never told about) getsNone— no team's policy, not a stranger's. New tests pin both halves.The two ambient getters that couldn't be removed
are_member_byo_keys_allowed/are_member_byo_endpoints_allowed's ambient (no-scope) form still has real, window-less callers this slice can't scope:app/src/ai/llms.rs(model-catalog resolution with no view in hand —first_party_key_source_for_provider,byo_key_source_for_model,LLMPreferences::custom_inference_enabled) andcrates/warp_tui/src/api_keys_menu.rs's Grok OAuth gate (TuiApiKeysMenuModelhas no window handle). Both #15461 and a further catalog-core migration have already looked at this cluster and deliberately left it ambient. Rather than invent an aggregation scheme nobody has designed, the two ambient forms are renamed toare_member_byo_keys_allowed_for_arbitrary_team/are_member_byo_endpoints_allowed_for_arbitrary_team— greppable, self-describing as the thing to remove, and no longer discoverable under the names the scoped getters use. Their bodies now go through the samehas_teams()-checked path viateam_byo_for_scope, so even the unscoped read stops substituting an arbitrary team's policy for a multi-team user. #15461 owns removing them:byo_key_source_for_model's two outside-llms.rscallers (terminal/input/models/data_source.rs:384,terminal/profile_model_selector.rs:2332) are UI surfaces that plausibly have a view in hand, so that cluster may be threadable rather than needing the aggregation these two PRs deferred on — worth revisiting in #15461, not this slice.Judgment calls kept from the original PR
is_managed_byok_byoe_enabled(newUserWorkspaceswrapper aroundcurrent_workspace_billing_metadata().is_managed_byok_byoe_enabled()) is kept: it's a plain, workspace-level (not team-derived) entitlement read with three call sites, matching this file's existing convention of many similar thin wrapper getters.has_team_first_party_key(moved from a free function inwarp_agent_page.rsontoUserWorkspaces, now scoped) is kept: it has no ambient sibling to duplicate, it's a real policy read that belongs next toteam_byo_for_scope, and the rename to drop_for_scopemakes it the one getter now.Known, pre-existing gaps not touched by this slice
params.model/custom_model_routersuntouched) — the picker's own gate is ambient, deferred to P1b.ApiKeysWidget) until aTeamsChanged/WindowTeamChangedevent fires — needs P11'sSurfaceWindowChanged([multi-team P11] Resolve the AI blocklist's team scope from its surface's current window #15441), not yet merged.Linked Issue
Linear
REV-2205. Not tracked by aready-to-spec/ready-to-implementGitHub issue.Testing
./script/format --check,cargo clippy -p warp --all-targets --tests -- -D warnings, and the full workspace clippy pass clean.cargo nextest run -p warpfor the touched modules (workspaces::user_workspaces,ai::agent::api,ai::blocklist::controller,settings_view::warp_agent_page,ai::llms) — 972/972 pass, including new regression tests for thehas_teams()invariant (member_byo_policy_denies_a_multi_team_users_teamless_window,apply_team_byo_policy_denies_a_multi_team_users_teamless_scope, and their teamless-user counterparts).Screenshots / Videos
Not applicable — no UI change.
Agent Mode
CHANGELOG-NONE