feat(proxy): let team admins create keys for team members behind an opt-in setting

A team admin calling POST /key/generate with a team they administer and the
user_id of another member of that team got a 403, and the dashboard only
offered the "Another User" owner option to proxy admins

Add a create_member_keys entry to team_admin_editable_team_fields. When a
proxy admin turns it on, a team admin can create a team key owned by another
member of a team they administer, through the API and through the Create Key
form. It stays off by default, so nothing changes until a proxy admin opts in

With the setting off, the 403 a team admin gets for this call now names the
setting to enable instead of saying they can only create keys for themselves
This commit is contained in:
ryan-crabbe-berri 2026-10-10 14:27:31 -07:00
parent 38d0333fcc
commit be3d97e19d
14 changed files with 550 additions and 40 deletions

View file

@ -115,6 +115,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_key_edit_verdict,
team_admin_key_request_or_raise,
team_admin_may_edit_member_key_budgets,
team_admin_member_key_creation_or_raise,
)
from litellm.proxy.management_helpers.access_group_key_sync import (
sync_key_access_group_membership,
@ -1215,6 +1216,28 @@ def _enforce_upperbound_key_params(
)
def _acting_as_team_admin_for_key_generation(
data: GenerateKeyRequest,
team_table: LiteLLM_TeamTableCachedObj | None,
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
"""Whether the caller is a team admin creating a team key owned by another member of that team.
Raises 403 when the caller administers the team but the create_member_keys permission is disabled.
"""
if (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
or team_table is None
or data.user_id is None
or data.user_id == user_api_key_dict.user_id
or _get_user_in_team(team_table=team_table, user_id=data.user_id) is None
or not is_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
):
return False
team_admin_member_key_creation_or_raise(_general_settings())
return True
async def _common_key_generation_helper(
data: GenerateKeyRequest,
user_api_key_dict: UserAPIKeyAuth,
@ -1229,9 +1252,15 @@ async def _common_key_generation_helper(
prisma_client,
)
acting_as_team_admin: Final = _acting_as_team_admin_for_key_generation(
data=data,
team_table=team_table,
user_api_key_dict=user_api_key_dict,
)
common_key_access_checks(
user_api_key_dict=user_api_key_dict,
data=data,
user_id=user_api_key_dict.user_id if acting_as_team_admin else None,
llm_router=llm_router,
premium_user=premium_user,
)
@ -2113,7 +2142,7 @@ async def generate_key_fn(
- key_alias: Optional[str] - User defined key alias
- key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
- team_id: Optional[str] - The team id of the key
- user_id: Optional[str] - The user id of the key
- user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`.
- agent_id: Optional[str] - The agent id associated with the key.
- organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
- project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.

View file

@ -1,8 +1,9 @@
"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on
/team/update, the ``raise_max_budget`` permission that lets a team admin grow the team's ``max_budget`` (never
above the organization's budget, never removing the cap), the ``projects`` permission for /project/new and
/project/update, and the ``member_key_budgets`` permission for budget fields on other members' keys via
/key/update."""
/project/update, the ``member_key_budgets`` permission for budget fields on other members' keys via
/key/update, and the ``create_member_keys`` permission for creating team keys owned by other members via
/key/generate."""
from collections.abc import Mapping
from dataclasses import dataclass
@ -30,10 +31,12 @@ SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"t
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION: Final = "raise_max_budget"
TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects"
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION: Final = "member_key_budgets"
TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION: Final = "create_member_keys"
SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | {
TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION,
TEAM_ADMIN_PROJECTS_PERMISSION,
TEAM_ADMIN_MEMBER_KEY_BUDGETS_PERMISSION,
TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION,
}
# spend is deliberately excluded: the stored row lags the live cross-pod counter, so a value-diff gate
@ -110,6 +113,25 @@ def team_admin_may_edit_member_key_budgets(general_settings: Mapping[str, object
)
def team_admin_may_create_member_keys(general_settings: Mapping[str, object]) -> bool:
return TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION in resolve_team_admin_editable_fields(
general_settings, frozenset({TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION})
)
def team_admin_member_key_creation_or_raise(general_settings: Mapping[str, object]) -> None:
if team_admin_may_create_member_keys(general_settings):
return
raise HTTPException(
status_code=403,
detail=(
"Team admins on this proxy cannot create keys for other team members. "
f"Ask a proxy admin to enable '{TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION}' "
f"under {_SETTINGS_LOCATION}."
),
)
def team_admin_may_raise_max_budget(general_settings: Mapping[str, object]) -> bool:
"""``raise_max_budget`` only takes effect alongside ``max_budget``: a grant to raise a field the team admin
may not edit at all is treated as not granted."""

View file

@ -365,6 +365,7 @@ class UISettings(LiteLLMBaseModel):
"removes the cap. "
"Include 'projects' to let team admins create and update projects for those teams. "
"Include 'member_key_budgets' to let team admins update budget fields on keys owned by other members of those teams. "
"Include 'create_member_keys' to let team admins create team keys owned by other members of those teams. "
"Empty means team admins cannot edit team settings or manage projects at all. "
"Proxy admins and org admins are not affected."
),

View file

@ -247,6 +247,12 @@ ROUTES: Final[tuple[Route, ...]] = (
Route("key_generate_team",
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id}),
team_admin=200, others=400, member=401, cleanup=_delete_key),
Route("key_generate_for_member",
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.member()}),
team_admin=403, others=400, member=401, cleanup=_delete_key),
Route("key_generate_for_member_permitted",
lambda s: Call("POST", "/key/generate", {"team_id": s.team_id, "user_id": s.member()}),
team_admin=200, others=400, member=401, permission=("create_member_keys",), cleanup=_delete_key),
Route("service_account_generate",
lambda s: Call("POST", "/key/service-account/generate", {"team_id": s.team_id, "key_alias": f"matrix-{uuid.uuid4().hex}"}),
team_admin=200, others=400, member=401, cleanup=_delete_key),

View file

@ -22327,6 +22327,99 @@ class TestTeamAdminMemberKeyBudgetUpdate:
assert "member_key_budgets" not in str(exc.value.detail)
class TestTeamAdminCreatesMemberKey:
"""LIT-6380: a team admin may create a team key owned by another member of that team
only when the proxy enables the 'create_member_keys' permission."""
def _team(self, members):
return LiteLLM_TeamTableCachedObj(team_id="team-1", members_with_roles=members)
def _admin_and_member(self):
return self._team([Member(user_id="team-admin-1", role="admin"), Member(user_id="member-1", role="user")])
async def _generate(self, monkeypatch, editable_fields, data, team_table, caller):
generate_key = AsyncMock(
return_value={"key": "sk-new", "expires": None, "user_id": data.user_id, "team_id": data.team_id}
)
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": editable_fields}
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock())
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", generate_key
)
await _common_key_generation_helper(
data=data,
user_api_key_dict=UserAPIKeyAuth(user_id=caller, user_role=LitellmUserRoles.INTERNAL_USER),
litellm_changed_by=None,
team_table=team_table,
)
return generate_key
@pytest.mark.asyncio
async def test_team_admin_creates_a_key_owned_by_a_member_when_enabled(self, monkeypatch):
generate_key = await self._generate(
monkeypatch,
["create_member_keys"],
GenerateKeyRequest(user_id="member-1", team_id="team-1"),
self._admin_and_member(),
caller="team-admin-1",
)
created = generate_key.call_args.kwargs
assert (created["user_id"], created["team_id"]) == ("member-1", "team-1")
@pytest.mark.asyncio
async def test_team_admin_is_pointed_at_the_setting_when_disabled(self, monkeypatch):
with pytest.raises(HTTPException) as exc:
await self._generate(
monkeypatch,
["member_key_budgets"],
GenerateKeyRequest(user_id="member-1", team_id="team-1"),
self._admin_and_member(),
caller="team-admin-1",
)
assert exc.value.status_code == 403
assert "'create_member_keys'" in str(exc.value.detail)
@pytest.mark.parametrize(
"data, team_members, caller",
[
pytest.param(
GenerateKeyRequest(user_id="member-1", team_id="team-1"),
[Member(user_id="member-2", role="user"), Member(user_id="member-1", role="user")],
"member-2",
id="ordinary_member_for_another_member",
),
pytest.param(
GenerateKeyRequest(user_id="outsider", team_id="team-1"),
[Member(user_id="team-admin-1", role="admin")],
"team-admin-1",
id="team_admin_for_a_user_outside_the_team",
),
pytest.param(
GenerateKeyRequest(user_id="member-1"),
None,
"team-admin-1",
id="personal_key_for_another_user",
),
],
)
@pytest.mark.asyncio
async def test_enabling_the_permission_grants_nothing_else(self, monkeypatch, data, team_members, caller):
with pytest.raises(HTTPException) as exc:
await self._generate(
monkeypatch,
["create_member_keys"],
data,
None if team_members is None else self._team(team_members),
caller=caller,
)
assert exc.value.status_code == 403
assert "User can only create keys for themselves" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_rotate_master_key_reencrypts_guardrail_params(monkeypatch):
import json

View file

@ -16,9 +16,11 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_edit_verdict,
team_admin_key_edit_verdict,
team_admin_key_request_or_raise,
team_admin_may_create_member_keys,
team_admin_may_edit_member_key_budgets,
team_admin_may_manage_projects,
team_admin_may_raise_max_budget,
team_admin_member_key_creation_or_raise,
team_admin_request_or_raise,
)
@ -192,6 +194,33 @@ class TestTeamAdminMayEditMemberKeyBudgets:
assert team_admin_may_edit_member_key_budgets({"team_admin_editable_team_fields": raw}) is False
class TestTeamAdminMayCreateMemberKeys:
def test_missing_setting_denies(self):
assert team_admin_may_create_member_keys({}) is False
def test_other_permissions_do_not_grant(self):
configured = {"team_admin_editable_team_fields": ["tpm_limit", "projects", "member_key_budgets"]}
assert team_admin_may_create_member_keys(configured) is False
def test_create_member_keys_entry_grants(self):
configured = {"team_admin_editable_team_fields": ["create_member_keys"]}
assert team_admin_may_create_member_keys(configured) is True
@pytest.mark.parametrize("raw", ["create_member_keys", 7, [1, 2]])
def test_malformed_setting_denies(self, raw):
assert team_admin_may_create_member_keys({"team_admin_editable_team_fields": raw}) is False
def test_or_raise_passes_when_granted(self):
assert team_admin_member_key_creation_or_raise({"team_admin_editable_team_fields": ["create_member_keys"]}) is None
def test_or_raise_is_a_403_naming_the_permission_and_where_to_enable_it(self):
with pytest.raises(HTTPException) as exc:
team_admin_member_key_creation_or_raise({"team_admin_editable_team_fields": ["member_key_budgets"]})
assert exc.value.status_code == 403
assert "'create_member_keys'" in exc.value.detail
assert "Settings > UI > Team admin editable fields" in exc.value.detail
class TestTeamAdminMayRaiseMaxBudget:
def test_missing_setting_denies(self):
assert team_admin_may_raise_max_budget({}) is False

View file

@ -4022,6 +4022,28 @@ class TestTeamAdminEditableTeamFieldsSetting:
assert stored["team_admin_editable_team_fields"] == ["member_key_budgets"]
assert team_admin_may_edit_member_key_budgets(general_settings) is True
def test_patch_accepts_the_create_member_keys_permission(self, monkeypatch):
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
team_admin_may_create_member_keys,
)
mock_prisma = self._as_proxy_admin(monkeypatch)
general_settings: dict = {}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
assert team_admin_may_create_member_keys(general_settings) is False
try:
response = client.patch(
"/update/ui_settings", json={"team_admin_editable_team_fields": ["create_member_keys"]}
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
assert stored["team_admin_editable_team_fields"] == ["create_member_keys"]
assert team_admin_may_create_member_keys(general_settings) is True
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
mock_prisma = self._as_proxy_admin(monkeypatch)
general_settings: dict = {"team_admin_editable_team_fields": ["tpm_limit"]}

View file

@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import type { Member } from "../networking";
import {
administersTeam,
anotherUserScope,
effectiveKeyOwner,
isTeamMemberOwner,
teamMemberOwnerOptions,
type TeamMembers,
} from "./createKeyOwnerAccess";
const VIEWER = "viewer";
const member = (user_id: string | null, role: string, user_email: string | null = null): Member => ({
user_id,
role,
user_email,
});
const administered: TeamMembers & { team_id: string } = {
team_id: "administered",
members_with_roles: [member(VIEWER, "admin"), member("alice", "user", "alice@example.com"), member("bob", "user")],
};
const joined: TeamMembers & { team_id: string } = {
team_id: "joined",
members_with_roles: [member(VIEWER, "user"), member("carol", "admin", "carol@example.com")],
};
const foreign: TeamMembers & { team_id: string } = {
team_id: "foreign",
members_with_roles: [member("dave", "admin")],
};
const viewer = {
userRole: "Internal User",
userId: VIEWER,
teamAdminEditableFields: ["create_member_keys"],
teams: [joined, administered],
};
describe("anotherUserScope", () => {
it("lets a team admin pick among team members once the proxy enables create_member_keys", () => {
expect(anotherUserScope(viewer)).toBe("team_members");
});
it.each([
["nothing is enabled", []],
["only other team admin permissions are enabled", ["max_budget", "member_key_budgets", "projects"]],
])("hides Another User from a team admin when %s", (_label, teamAdminEditableFields) => {
expect(anotherUserScope({ ...viewer, teamAdminEditableFields })).toBe("none");
});
it.each([
["only a plain member of their teams", [joined]],
["an admin of no listed team", [foreign]],
["in no team", []],
["still waiting on the team list", null],
])("hides Another User with the permission on from a user who is %s", (_label, teams) => {
expect(anotherUserScope({ ...viewer, teams })).toBe("none");
});
it("hides Another User when the session carries no user id", () => {
expect(anotherUserScope({ ...viewer, userId: null })).toBe("none");
});
it.each([
["with the permission off and no teams", { teamAdminEditableFields: [], teams: [] }],
["with the permission on and an administered team", {}],
["before the team list has loaded", { teams: null }],
])("lets a proxy admin pick any user %s", (_label, overrides) => {
expect(anotherUserScope({ ...viewer, userRole: "Admin", ...overrides })).toBe("any_user");
});
});
describe("effectiveKeyOwner", () => {
it.each(["any_user", "team_members"] as const)("keeps another_user for a viewer scoped to %s", (scope) => {
expect(effectiveKeyOwner("another_user", scope)).toBe("another_user");
});
it("falls back to you when another_user is requested by a viewer who may not pick one", () => {
expect(effectiveKeyOwner("another_user", "none")).toBe("you");
});
it.each(["you", "service_account", "agent"])(
"leaves %s alone for a viewer who may not pick another user",
(owner) => {
expect(effectiveKeyOwner(owner, "none")).toBe(owner);
},
);
});
describe("administersTeam", () => {
it("narrows a team list to the teams the viewer administers", () => {
const teams = [joined, administered, foreign];
expect(teams.filter((team) => administersTeam(team, VIEWER)).map((team) => team.team_id)).toStrictEqual([
"administered",
]);
});
it.each([
["no team", null],
["a team with no member list", {}],
["a team whose member list is null", { members_with_roles: null }],
])("is false for %s", (_label, team) => {
expect(administersTeam(team, VIEWER)).toBe(false);
});
});
describe("teamMemberOwnerOptions", () => {
it("lists exactly the selected team's members, labelled by email when the team has one", () => {
expect(teamMemberOwnerOptions(administered, VIEWER)).toStrictEqual([
{ value: VIEWER, label: VIEWER },
{ value: "alice", label: "alice@example.com (alice)" },
{ value: "bob", label: "bob" },
]);
});
it("skips a member row that has no user id", () => {
const team = { members_with_roles: [member(VIEWER, "admin"), member(null, "user", "pending@example.com")] };
expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([{ value: VIEWER, label: VIEWER }]);
});
it.each([
["no team is selected", null],
["the selected team is one the viewer only belongs to", joined],
["the selected team is one the viewer is not in", foreign],
])("offers nobody when %s", (_label, team) => {
expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([]);
});
});
describe("isTeamMemberOwner", () => {
it("keeps an owner who belongs to the selected team", () => {
expect(isTeamMemberOwner("alice", administered, VIEWER)).toBe(true);
});
it.each([
["is in another team only", "carol", administered],
["was picked before the team was cleared", "alice", null],
["belongs to a team the viewer does not administer", "carol", joined],
["is not set", null, administered],
])("rejects an owner who %s", (_label, ownerId, team) => {
expect(isTeamMemberOwner(ownerId, team, VIEWER)).toBe(false);
});
});

View file

@ -0,0 +1,51 @@
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION } from "@/components/team/teamAdminEditAccess";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import type { Member } from "../networking";
export type AnotherUserScope = "any_user" | "team_members" | "none";
export interface TeamMembers {
readonly members_with_roles?: Member[] | null;
}
export interface KeyOwnerViewer {
readonly userRole: string | null;
readonly userId: string | null;
readonly teamAdminEditableFields: readonly string[];
readonly teams: readonly TeamMembers[] | null;
}
export const administersTeam = (team: TeamMembers | null, userId: string | null): boolean =>
userId !== null && isUserTeamAdminForSingleTeam(team?.members_with_roles ?? null, userId);
export const anotherUserScope = ({
userRole,
userId,
teamAdminEditableFields,
teams,
}: KeyOwnerViewer): AnotherUserScope => {
if (isProxyAdminRole(userRole ?? "")) return "any_user";
if (!teamAdminEditableFields.includes(TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION)) return "none";
return (teams ?? []).some((team) => administersTeam(team, userId)) ? "team_members" : "none";
};
export const effectiveKeyOwner = (requestedOwner: string, scope: AnotherUserScope): string =>
requestedOwner === "another_user" && scope === "none" ? "you" : requestedOwner;
export const teamMemberOwnerOptions = (team: TeamMembers | null, viewerId: string | null): SearchSelectOption[] => {
if (!administersTeam(team, viewerId)) return [];
return (team?.members_with_roles ?? []).flatMap((member) =>
member.user_id
? [
{
value: member.user_id,
label: member.user_email ? `${member.user_email} (${member.user_id})` : member.user_id,
},
]
: [],
);
};
export const isTeamMemberOwner = (ownerId: unknown, team: TeamMembers | null, viewerId: string | null): boolean =>
teamMemberOwnerOptions(team, viewerId).some((option) => option.value === ownerId);

View file

@ -649,6 +649,58 @@ describe("CreateKey", () => {
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
});
describe("for a team admin", () => {
const administeredTeam = {
team_id: "team-admin-of",
team_alias: "Administered Team",
models: [],
members_with_roles: [
{ user_id: "test-user-id", role: "admin" },
{ user_id: "member-1", role: "user", user_email: "member@example.com" },
],
};
const joinedTeam = {
team_id: "team-member-of",
team_alias: "Joined Team",
models: [],
members_with_roles: [
{ user_id: "test-user-id", role: "user" },
{ user_id: "outsider-1", role: "admin", user_email: "outsider@example.com" },
],
};
beforeEach(() => {
state.authorized = { ...state.authorized, userRole: "Internal User" };
state.teams = [joinedTeam, administeredTeam];
});
it("hides the Another User option until the proxy enables create_member_keys", async () => {
state.uiSettings = { team_admin_editable_team_fields: ["max_budget"] };
await openModal({ teams: state.teams as unknown as Team[] });
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
});
it("creates a key for a member of an administered team without the proxy-wide user search", async () => {
state.uiSettings = { team_admin_editable_team_fields: ["create_member_keys"] };
await openModal({ teams: state.teams as unknown as Team[] });
await nameTheKey();
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
await userEvent.click(await screen.findByLabelText(/^Team/));
expect(await screen.findByRole("option", { name: /Administered Team/ })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /Joined Team/ })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: /Administered Team/ }));
await userEvent.click(await screen.findByLabelText(/^User ID/));
await userEvent.click(await screen.findByRole("option", { name: "member@example.com (member-1)" }));
await submit();
expect(await createdPayload()).toMatchObject({ user_id: "member-1", team_id: "team-admin-of" });
expect(userFilterUICall).not.toHaveBeenCalled();
});
});
it("routes a service account through the service account endpoint and stamps the alias into metadata", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });

View file

@ -6,6 +6,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@ -80,6 +81,13 @@ import {
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import {
administersTeam,
anotherUserScope,
effectiveKeyOwner,
isTeamMemberOwner,
teamMemberOwnerOptions,
} from "./createKeyOwnerAccess";
import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
import { simplifyKeyGenerateError } from "./utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -248,7 +256,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const [apiKey, setApiKey] = useState(null);
const [userModels, setUserModels] = useState<string[]>([]);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
const [keyOwner, setKeyOwner] = useState("you");
const [requestedKeyOwner, setKeyOwner] = useState("you");
const [hasPrefilled, setHasPrefilled] = useState(false);
const [pendingPrefillModels, setPendingPrefillModels] = useState<string[] | null>(null);
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
@ -279,6 +287,13 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const selectedModels: string[] = (useWatch({ control: form.control, name: "models" }) as string[] | undefined) ?? [];
const teamAdminEditableFields = parseTeamAdminEditableFields(uiSettingsData?.values);
const keyOwnerViewer = { userRole, userId: userID, teamAdminEditableFields, teams };
const ownerScope = anotherUserScope(keyOwnerViewer);
const keyOwner = effectiveKeyOwner(requestedKeyOwner, ownerScope);
const ownerLimitedToTeamMembers = keyOwner === "another_user" && ownerScope === "team_members";
const memberOwnerOptions = teamMemberOwnerOptions(selectedCreateKeyTeam, userID);
const ownerRequiresTeam = keyOwner === "service_account" || ownerLimitedToTeamMembers;
const handleCancel = () => {
setIsModalVisible(false);
setApiKey(null);
@ -383,14 +398,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
// Apply prefill data if provided
if (prefillData) {
// Set key owner (owned_by) - validate that "another_user" is only allowed for Admin
if (prefillData.owned_by) {
if (prefillData.owned_by === "another_user" && userRole !== "Admin") {
// Ignore invalid owned_by for non-admin users, fall back to default
setKeyOwner("you");
} else {
setKeyOwner(prefillData.owned_by);
}
setKeyOwner(prefillData.owned_by);
}
// Set team - find the team by ID and set it (only if team exists in user's teams)
@ -616,6 +625,23 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
}
};
const changeKeyOwner = (owner: string) => {
setKeyOwner(owner);
const limitsOwnerToTeamMembers = owner === "another_user" && ownerScope === "team_members";
if (limitsOwnerToTeamMembers && selectedCreateKeyTeam && !administersTeam(selectedCreateKeyTeam, userID)) {
selectTeam(null);
form.setValue("team_id", null);
}
};
useEffect(() => {
if (!ownerLimitedToTeamMembers) return;
const owner = form.getValues("user_id");
if (owner != null && !isTeamMemberOwner(owner, selectedCreateKeyTeam, userID)) {
form.setValue("user_id", null);
}
}, [ownerLimitedToTeamMembers, selectedCreateKeyTeam, userID, form]);
const changeProject = (write: FieldWrite) => (projectId: string | null) => {
write(projectId);
if (!projectId) {
@ -679,7 +705,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
<RadioGroup
className="flex flex-wrap items-center gap-4"
value={keyOwner}
onValueChange={(value: unknown) => setKeyOwner(String(value))}
onValueChange={(value: unknown) => changeKeyOwner(String(value))}
>
<label className={KEY_OWNER_LABEL_CLASS}>
<RadioGroupItem value="you" />
@ -689,7 +715,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
<RadioGroupItem value="service_account" />
Service Account
</label>
{userRole === "Admin" && (
{ownerScope !== "none" && (
<label className={KEY_OWNER_LABEL_CLASS}>
<RadioGroupItem value="another_user" />
Another User
@ -720,30 +746,50 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
`Please input the user ID of the user you are assigning the key to`,
)}
>
{(control) => (
<div>
<div className="mb-2 flex">
<PaginatedSearchSelect
options={userOptions}
value={typeof control.value === "string" ? control.value : undefined}
onValueChange={control.onChange}
onSearchChange={fetchUsers}
isLoading={userSearchLoading}
placeholder="Type email or user ID to search for users"
emptyText="No users found"
loadingText="Searching..."
inputId={control.id}
aria-required={control["aria-required"] === "true" ? true : undefined}
aria-invalid={control["aria-invalid"] === "true" ? true : undefined}
aria-describedby={control["aria-describedby"]}
/>
<Button variant="outline" className="ml-2" onClick={() => setIsCreateUserModalVisible(true)}>
Create User
</Button>
{(control) =>
ownerLimitedToTeamMembers ? (
<SearchSelect
inputId={control.id}
options={memberOwnerOptions}
value={typeof control.value === "string" ? control.value : null}
onValueChange={control.onChange}
disabled={memberOwnerOptions.length === 0}
placeholder={
memberOwnerOptions.length === 0
? "Select a team you administer first"
: "Select a member of the team"
}
emptyText="No team members found"
/>
) : (
<div>
<div className="mb-2 flex">
<PaginatedSearchSelect
options={userOptions}
value={typeof control.value === "string" ? control.value : undefined}
onValueChange={control.onChange}
onSearchChange={fetchUsers}
isLoading={userSearchLoading}
placeholder="Type email or user ID to search for users"
emptyText="No users found"
loadingText="Searching..."
inputId={control.id}
aria-required={control["aria-required"] === "true" ? true : undefined}
aria-invalid={control["aria-invalid"] === "true" ? true : undefined}
aria-describedby={control["aria-describedby"]}
/>
<Button
variant="outline"
className="ml-2"
onClick={() => setIsCreateUserModalVisible(true)}
>
Create User
</Button>
</div>
<div className="text-xs text-muted-foreground">Search by email or user ID to find users</div>
</div>
<div className="text-xs text-muted-foreground">Search by email or user ID to find users</div>
</div>
)}
)
}
</MountedFormField>
)}
{keyOwner === "agent" && (
@ -803,9 +849,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
}
name="team_id"
className="mt-4"
required={keyOwner === "service_account"}
rules={requiredRule(keyOwner === "service_account", "Please select a team for the service account")}
help={keyOwner === "service_account" ? "required" : ""}
required={ownerRequiresTeam}
rules={requiredRule(
ownerRequiresTeam,
ownerLimitedToTeamMembers
? "Please select the team the key's owner belongs to"
: "Please select a team for the service account",
)}
help={ownerRequiresTeam ? "required" : ""}
>
{(control) => (
<TeamDropdown
@ -815,6 +866,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
disabled={selectedProjectId !== null}
organizationId={selectedOrganizationId}
onTeamSelect={selectTeam}
filterTeam={ownerLimitedToTeamMembers ? (team) => administersTeam(team, userID) : undefined}
/>
)}
</MountedFormField>

View file

@ -15,6 +15,7 @@ describe("teamAdminFieldLabel", () => {
["max_budget", "Max Budget (USD)"],
["raise_max_budget", "Raise the team's max budget"],
["projects", "Create and update projects"],
["create_member_keys", "Create keys for team members"],
])("names %s the way the team settings form does", (field, label) => {
expect(teamAdminFieldLabel(field)).toBe(label);
});

View file

@ -5,6 +5,8 @@ export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
export const TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION = "create_member_keys";
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
@ -57,6 +59,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
["projects", "Create and update projects"],
["member_key_budgets", "Update budgets on team members' keys"],
[TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "Create keys for team members"],
]);
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;

View file

@ -8333,7 +8333,7 @@ export interface paths {
* - key_alias: Optional[str] - User defined key alias
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
* - team_id: Optional[str] - The team id of the key
* - user_id: Optional[str] - The user id of the key
* - user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`.
* - agent_id: Optional[str] - The agent id associated with the key.
* - organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
* - project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.