Skip to content

[multi-team P1] Rework: require a team scope on the member BYO key/endpoint getters - #15446

Open
warp-agent-staging[bot] wants to merge 1 commit into
masterfrom
factory/multi-team-p1-byo-credentials
Open

[multi-team P1] Rework: require a team scope on the member BYO key/endpoint getters#15446
warp-agent-staging[bot] wants to merge 1 commit into
masterfrom
factory/multi-team-p1-byo-credentials

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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 against current_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_scope variants 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_key now take scope: &impl TeamScope directly (dropped the _for_scope suffix — 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 — no Option<&dyn TeamScope> anywhere.
  • RequestParams::new already gained a scope: &impl TeamScope parameter from [multi-team P3a-1] Scope the AI autonomy accessor to the window's team #15443 (the AI-autonomy slice, merged to master mid-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 the apply_team_byo_policy method entirely along with the deferred-decision machinery it needed.
  • Real invariant fix, not just a rename. team_byo_for_scope's no-team arm used to read current_workspace().settings.team_byo unconditionally. Per TeamScope's own documented contract (already on master), 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 checks has_teams(): a genuinely teamless user still gets the workspace fallback; a multi-team user's teamless scope (window with no team selected, or a window UserWorkspaces was never told about) gets None — 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) and crates/warp_tui/src/api_keys_menu.rs's Grok OAuth gate (TuiApiKeysMenuModel has 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 to are_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 same has_teams()-checked path via team_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.rs callers (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 (new UserWorkspaces wrapper around current_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 in warp_agent_page.rs onto UserWorkspaces, now scoped) is kept: it has no ambient sibling to duplicate, it's a real policy read that belongs next to team_byo_for_scope, and the rename to drop _for_scope makes it the one getter now.

Known, pre-existing gaps not touched by this slice

  • A selected member custom model can dangle once its endpoint is stripped (params.model/custom_model_routers untouched) — the picker's own gate is ambient, deferred to P1b.
  • Dragging the Settings tab between windows on different teams doesn't recompute the imperative widget state (ApiKeysWidget) until a TeamsChanged/WindowTeamChanged event fires — needs P11's SurfaceWindowChanged ([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 a ready-to-spec/ready-to-implement GitHub 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 warp for 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 the has_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).
  • No UI surface changed, so no manual/visual verification was needed.

Screenshots / Videos

Not applicable — no UI change.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-NONE

@cla-bot cla-bot Bot added the cla-signed label Aug 22, 2026
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

Comment @warp-factory on this PR to send it follow-up work.

View run View conversation

@warp-agent-staging
warp-agent-staging Bot marked this pull request as ready for review August 22, 2026 08:46

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 false from both allow-getters, so the restriction stands rather than resolving to another team's team_byo. The tempting simplification of those getters is is_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 the two_teams_with_opposing_byo_policy fixture — it deliberately uses the captured operation scope rather than a window scope, because team_context_for_window resolves 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.

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 type PrivacySettings that was never registered. It is the only new test calling update_workspaces, which reaches notify_and_emit_teams_changedPrivacySettings::handle(ctx); user_workspaces_tests.rs:146 registers it, api_tests.rs:149 does not. Fix is one line in register_workspace_and_api_key_managerapp.add_singleton_model(crate::PrivacySettings::mock); (note crate::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_request computes byo_allowed from is_byo_api_key_enabled alone — plan entitlement, no team policy — and writes the refreshed token back into the already-stripped params at :563-565. apply_team_byo_policy clears grok_oauth_access_token but leaves api_keys as Some(..) whenever Bedrock or GEAP credentials are attached, i.e. precisely the org-managed deployment where a team_byo restriction is most likely set — so the member's Grok token goes out despite the admin restriction. Where no org credential is attached, api_keys is None and 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 by apply_team_byo_policy) and AND it into byo_allowed at :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_window resolves the uid through team_from_uid before storing it, so a departed team collapses to team_uid() == None and takes the permissive branch; team_context delegates to it, so the handle path is identical. The Some(_) deny branch is reachable only from TeamContextForOperation, 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 to teams[0], literally the "resolving to some other team's team_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 means TeamContext carrying the raw uid with team_from_scope becoming the resolution point, which changes P0's merged type and interacts with reconciliation's teams[0] fallback.
  • app/src/ai/agent/api.rs:454 — the selected model can dangle once endpoints are stripped. apply_team_byo_policy clears custom_model_providers but leaves params.model and custom_model_routers, whose identifiers are custom-endpoint config_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 neither WindowTeamChanged nor TeamsChanged. Render-time reads follow immediately, but the imperative state set in ApiKeysWidget::new and sync_custom_endpoint_buttons does 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's SurfaceWindowChanged (#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.

…indow's team

Squashed for rebase onto master post-#15443. See PR description for the
full account of the rework.
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

Reply after reworking this PR in place (now at ce037324), addressing both reviews:

  • Departed-team test (first review): already present as member_byo_policy_denies_a_scope_naming_an_unresolvable_team in user_workspaces_tests.rs, using the same TeamContextForOperation::new_for_test/two_teams_with_opposing_byo_policy shape. Passing.
  • PrivacySettings panic: the failing test no longer exists — RequestParams's BYO policy is now resolved inline in RequestParams::new (via the scope param #15443 already threads in), so api_tests.rs no longer builds ambient RequestParams or calls update_workspaces. Only the getter-level tests (user_workspaces_tests.rs) exercise the has_teams()/scope behavior now.
  • Grok re-injection hole: fixed (this predates my rework — response_stream.rs already gates on params.member_byo_credentials_allowed, which RequestParams::new now sets from the real scope at construction).
  • Departed-team doc staleness: team_context/team_context_for_view now carry #15443's language directly ("a view that has left its window resolves the same way as a window with no team"). That's the intended behavior, confirmed with the requester — not something this PR should make "real." No doc claims otherwise anymore.
  • Dangling model/router after endpoint strip: left as the known, documented gap it already was — out of scope for this slice (picker gate is P1b's).
  • Settings-tab drag-between-windows: left as a known gap pending P11's SurfaceWindowChanged ([multi-team P11] Resolve the AI blocklist's team scope from its surface's current window #15441), same disposition as before.

Full rework details are in the PR description.

Responding as wilson: Open session · View in factory

@warp-agent-staging
warp-agent-staging Bot force-pushed the factory/multi-team-p1-byo-credentials branch from 4881d72 to ce03732 Compare August 23, 2026 07:12
@warp-agent-staging warp-agent-staging Bot changed the title [multi-team P1] Scope member BYO key/endpoint policy to the window's team [multi-team P1] Rework: require a team scope on the member BYO key/endpoint getters Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants