Skip to content

Commit 2762095

Browse files
authored
Team-Level API Token Scoping with Public-Only Token Support (#1177)
* team level resources scoping for api tokens Signed-off-by: Keval Mahajan <[email protected]> * public-only token support Signed-off-by: Keval Mahajan <[email protected]> * regex pattern matching covering all required apis Signed-off-by: Keval Mahajan <[email protected]> * linting issues Signed-off-by: Keval Mahajan <[email protected]> * minor changes Signed-off-by: Keval Mahajan <[email protected]> * web linting Signed-off-by: Keval Mahajan <[email protected]> --------- Signed-off-by: Keval Mahajan <[email protected]>
1 parent 1f54133 commit 2762095

File tree

7 files changed

+636
-59
lines changed

7 files changed

+636
-59
lines changed

mcpgateway/middleware/token_scoping.py

Lines changed: 327 additions & 6 deletions
Large diffs are not rendered by default.

mcpgateway/routers/tokens.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ async def create_token(
5959
"""
6060
service = TokenCatalogService(db)
6161

62+
# if not request.team_id:
63+
# raise HTTPException(
64+
# status_code=status.HTTP_400_BAD_REQUEST, detail="team_id is required. Please select a specific team before creating a token. You cannot create tokens while viewing 'All Teams'."
65+
# )
66+
6267
# Convert request to TokenScope if provided
6368
scope = None
6469
if request.scope:
@@ -78,7 +83,7 @@ async def create_token(
7883
scope=scope,
7984
expires_in_days=request.expires_in_days,
8085
tags=request.tags,
81-
team_id=getattr(request, "team_id", None),
86+
team_id=request.team_id,
8287
)
8388

8489
# Create TokenResponse for the token info

mcpgateway/services/token_catalog_service.py

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -299,99 +299,124 @@ async def create_token(
299299
tags: Optional[List[str]] = None,
300300
team_id: Optional[str] = None,
301301
) -> tuple[EmailApiToken, str]:
302-
"""Create a new API token for user or team.
302+
"""
303+
Create a new API token with team-level scoping and additional configurations.
304+
305+
This method generates a JWT-based API token with team-level scoping and optional security configurations,
306+
such as expiration, permissions, IP restrictions, and usage limits. The token is associated with a user
307+
and a specific team, ensuring access control and multi-tenancy support.
308+
309+
The function will:
310+
- Validate the existence of the user.
311+
- Ensure the user is an active member of the specified team.
312+
- Verify that the token name is unique for the user+team combination.
313+
- Generate a JWT with the specified scoping parameters (e.g., permissions, IP, etc.).
314+
- Store the token in the database with the relevant details and return the token and raw JWT string.
303315
304316
Args:
305-
user_email: Owner's email address
306-
name: Human-readable token name
307-
description: Optional token description
308-
scope: Token scoping configuration
309-
expires_in_days: Optional expiry in days
310-
tags: Optional organizational tags
311-
team_id: Optional team ID for team-scoped tokens
317+
user_email (str): The email address of the user requesting the token.
318+
name (str): A unique, human-readable name for the token (must be unique per user+team).
319+
description (Optional[str]): A description for the token (default is None).
320+
scope (Optional[TokenScope]): The scoping configuration for the token, including permissions,
321+
server ID, IP restrictions, etc. (default is None).
322+
expires_in_days (Optional[int]): The expiration time in days for the token (None means no expiration).
323+
tags (Optional[List[str]]): A list of organizational tags for the token (default is an empty list).
324+
team_id (Optional[str]): The team ID to which the token should be scoped. This is required for team-level scoping.
312325
313326
Returns:
314-
Tuple of (EmailApiToken, raw_token_string)
327+
tuple[EmailApiToken, str]: A tuple where the first element is the `EmailApiToken` database record and
328+
the second element is the raw JWT token string. The `EmailApiToken` contains the database record with the
329+
token details.
315330
316331
Raises:
317-
ValueError: If user not found, token name exists, or team access denied
332+
ValueError: If any of the following validation checks fail:
333+
- The `user_email` does not correspond to an existing user.
334+
- The `team_id` is missing or the user is not an active member of the specified team.
335+
- A token with the same name already exists for the given user and team.
336+
- Invalid token configuration (e.g., invalid expiration date).
318337
319338
Examples:
320339
>>> # This method requires database operations, shown for reference
321340
>>> service = TokenCatalogService(None) # Would use real DB session
322341
>>> # token, raw_token = await service.create_token(...)
323342
>>> # Returns (EmailApiToken, raw_token_string) tuple
324343
"""
344+
# # Enforce team-level scoping requirement
345+
# if not team_id:
346+
# raise ValueError("team_id is required for token creation. " "Please select a specific team before creating a token. " "You cannot create tokens while viewing 'All Teams'.")
347+
325348
# Validate user exists
326349
user = self.db.execute(select(EmailUser).where(EmailUser.email == user_email)).scalar_one_or_none()
327350

328351
if not user:
329352
raise ValueError(f"User not found: {user_email}")
330353

331-
# Validate team access if team_id is provided
354+
# Validate team exists and user is active member
332355
if team_id:
333356
# First-Party
334357
from mcpgateway.db import EmailTeam, EmailTeamMember # pylint: disable=import-outside-toplevel
335358

336359
# Check if team exists
337360
team = self.db.execute(select(EmailTeam).where(EmailTeam.id == team_id)).scalar_one_or_none()
361+
338362
if not team:
339363
raise ValueError(f"Team not found: {team_id}")
340364

341-
# Check if user is a team OWNER
365+
# Verify user is an active member of the team
342366
membership = self.db.execute(
343-
select(EmailTeamMember).where(and_(EmailTeamMember.team_id == team_id, EmailTeamMember.user_email == user_email, EmailTeamMember.role == "owner", EmailTeamMember.is_active.is_(True)))
367+
select(EmailTeamMember).where(and_(EmailTeamMember.team_id == team_id, EmailTeamMember.user_email == user_email, EmailTeamMember.is_active.is_(True)))
344368
).scalar_one_or_none()
345369

346370
if not membership:
347-
raise ValueError(f"Only team owners can create API keys for team {team_id}")
348-
349-
# Check for duplicate token name (scoped by user and team)
350-
name_check_conditions = [EmailApiToken.user_email == user_email, EmailApiToken.name == name, EmailApiToken.is_active.is_(True)]
371+
raise ValueError(f"User {user_email} is not an active member of team {team_id}. " f"Only team members can create tokens for the team.")
351372

352-
if team_id:
353-
name_check_conditions.append(EmailApiToken.team_id == team_id)
354-
else:
355-
name_check_conditions.append(EmailApiToken.team_id.is_(None))
356-
357-
existing_token = self.db.execute(select(EmailApiToken).where(and_(*name_check_conditions))).scalar_one_or_none()
373+
# Check for duplicate active token name for this user+team
374+
existing_token = self.db.execute(
375+
select(EmailApiToken).where(and_(EmailApiToken.user_email == user_email, EmailApiToken.name == name, EmailApiToken.team_id == team_id, EmailApiToken.is_active.is_(True)))
376+
).scalar_one_or_none()
358377

359378
if existing_token:
360-
scope_desc = f" for team {team_id}" if team_id else ""
361-
raise ValueError(f"Token name '{name}' already exists{scope_desc}")
379+
raise ValueError(f"Token with name '{name}' already exists for user {user_email} in team {team_id}. Please choose a different name.")
362380

363-
# Calculate expiry
381+
# CALCULATE EXPIRATION DATE
364382
expires_at = None
365383
if expires_in_days:
366384
expires_at = utc_now() + timedelta(days=expires_in_days)
367385

368-
# Generate JWT token with proper claims and user admin status
369-
raw_token = await self._generate_token(user_email=user_email, team_id=team_id, expires_at=expires_at, scope=scope, user=user)
386+
# Generate JWT token with all necessary claims
387+
raw_token = await self._generate_token(user_email=user_email, team_id=team_id, expires_at=expires_at, scope=scope, user=user) # Pass user object to include admin status
388+
389+
# Hash token for secure storage
370390
token_hash = self._hash_token(raw_token)
371391

372-
# Create token record
392+
# Create database record
373393
api_token = EmailApiToken(
394+
id=str(uuid.uuid4()),
374395
user_email=user_email,
396+
team_id=team_id, # Store team association
375397
name=name,
376-
token_hash=token_hash,
377398
description=description,
399+
token_hash=token_hash, # Store hash, not raw token
378400
expires_at=expires_at,
379401
tags=tags or [],
380-
team_id=team_id,
402+
# Store scoping information
381403
server_id=scope.server_id if scope else None,
382404
resource_scopes=scope.permissions if scope else [],
383405
ip_restrictions=scope.ip_restrictions if scope else [],
384406
time_restrictions=scope.time_restrictions if scope else {},
385407
usage_limits=scope.usage_limits if scope else {},
408+
# Token status
409+
is_active=True,
410+
created_at=utc_now(),
411+
last_used=None,
386412
)
387413

388414
self.db.add(api_token)
389415
self.db.commit()
390416
self.db.refresh(api_token)
391417

392-
scope_desc = f" for team {team_id}" if team_id else ""
393-
logger.info(f"Created API token '{name}' for user {user_email}{scope_desc}")
394-
418+
token_type = f"team-scoped (team: {team_id})" if team_id else "public-only"
419+
logger.info(f"Created {token_type} API token '{name}' for user {user_email}. " f"Token ID: {api_token.id}, Expires: {expires_at or 'Never'}")
395420
return api_token, raw_token
396421

397422
async def list_user_tokens(self, user_email: str, include_inactive: bool = False, limit: int = 100, offset: int = 0) -> List[EmailApiToken]:

0 commit comments

Comments
 (0)