Skip to content

Commit 897ccaa

Browse files
authored
fix: llm proxy hardening and behavior consistency h-batch-7-final (#3120)
* fix: llm proxy hardening and behavior consistency Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * Update AGENTS.md Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * chore: lint docstring hardening and behavior consistency Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: harden alembic sqlite migration compatibility Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: tighten llm token scoping and update rbac docs Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 7cda2f0 commit 897ccaa

19 files changed

+687
-215
lines changed

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ The `teams` claim in JWT tokens determines resource visibility:
9595
- Admin bypass requires BOTH `teams: null` AND `is_admin: true`
9696
- `normalize_token_teams()` in `mcpgateway/auth.py` is the single source of truth
9797

98+
### Security Invariants (Required)
99+
100+
- Treat `public` as platform-public scope, not internet-anonymous scope.
101+
- Explicit exception: when `MCP_REQUIRE_AUTH=false`, unauthenticated `/mcp` requests are allowed with public-only visibility.
102+
- Keep the two-layer model on every path:
103+
- Layer 1: token scoping controls what a caller can see.
104+
- Layer 2: RBAC controls what a caller can do.
105+
- Do not re-implement token team interpretation logic; always use `normalize_token_teams()` in `mcpgateway/auth.py`.
106+
- Do not accept inbound client auth tokens via URL query parameters.
107+
- Legacy `INSECURE_ALLOW_QUERYPARAM_AUTH` is interop-only for outbound peer auth and must remain opt-in and host-restricted.
108+
- High-risk transports must be feature-flagged and disabled by default.
109+
- Transport/session endpoints must authenticate before session establishment (or message forwarding) and enforce RBAC before processing actions.
110+
- Token-scoped route authorization must be default-deny for unmapped protected paths.
111+
- Never trust client-provided ownership fields (`owner_email`, `team_id`, session owner); derive authorization from authenticated identity and server-side state.
112+
- Security-sensitive changes must include deny-path regression tests (unauthenticated, wrong team, insufficient permissions, feature disabled).
113+
98114
### Built-in Roles
99115

100116
| Role | Scope | Key Permissions |

docs/docs/manage/rbac.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,10 @@ Logical groups that:
6363
| Role | Scope | Permissions |
6464
|------|-------|-------------|
6565
| `platform_admin` | global | `["*"]` (all permissions) |
66-
| `team_admin` | team | admin.dashboard, gateways.read, gateways.create, gateways.update, gateways.delete, servers.read, servers.create, servers.update, servers.delete, teams.read, teams.update, teams.join, teams.delete, teams.manage_members, tools.read, tools.create, tools.update, tools.delete, tools.execute, resources.read, resources.create, resources.update, resources.delete, prompts.read, prompts.create, prompts.update, prompts.delete, a2a.read, a2a.create, a2a.update, a2a.delete, a2a.invoke |
67-
| `developer` | team | admin.dashboard, gateways.read, gateways.create, gateways.update, gateways.delete, servers.read, servers.create, servers.update, servers.delete, teams.join, tools.read, tools.create, tools.update, tools.delete, tools.execute, resources.read, resources.create, resources.update, resources.delete, prompts.read, prompts.create, prompts.update, prompts.delete, a2a.read, a2a.create, a2a.update, a2a.delete, a2a.invoke |
68-
| `viewer` | team | admin.dashboard, gateways.read, servers.read, teams.join, tools.read, resources.read, prompts.read, a2a.read |
69-
| `platform_viewer` | global | admin.dashboard, gateways.read, servers.read, teams.join, tools.read, resources.read, prompts.read, a2a.read |
66+
| `team_admin` | team | admin.dashboard, gateways.read, gateways.create, gateways.update, gateways.delete, servers.read, servers.create, servers.update, servers.delete, teams.read, teams.update, teams.join, teams.delete, teams.manage_members, tools.read, tools.create, tools.update, tools.delete, tools.execute, resources.read, resources.create, resources.update, resources.delete, prompts.read, prompts.create, prompts.update, prompts.delete, a2a.read, a2a.create, a2a.update, a2a.delete, a2a.invoke, llm.read, llm.invoke |
67+
| `developer` | team | admin.dashboard, gateways.read, gateways.create, gateways.update, gateways.delete, servers.read, servers.create, servers.update, servers.delete, teams.join, tools.read, tools.create, tools.update, tools.delete, tools.execute, resources.read, resources.create, resources.update, resources.delete, prompts.read, prompts.create, prompts.update, prompts.delete, a2a.read, a2a.create, a2a.update, a2a.delete, a2a.invoke, llm.read, llm.invoke |
68+
| `viewer` | team | admin.dashboard, gateways.read, servers.read, teams.join, tools.read, resources.read, prompts.read, a2a.read, llm.read |
69+
| `platform_viewer` | global | admin.dashboard, gateways.read, servers.read, teams.join, tools.read, resources.read, prompts.read, a2a.read, llm.read |
7070

7171
!!! info "Default Role Assignment"
7272
**New users automatically receive up to two roles upon creation:**

mcpgateway/alembic/versions/191a2def08d7_resource_rename_template_to_uri_template.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def upgrade() -> None:
2525
conn = op.get_bind()
2626
inspector = sa.inspect(conn)
2727

28+
if "resources" not in inspector.get_table_names():
29+
return
30+
2831
columns = [c["name"] for c in inspector.get_columns("resources")]
2932

3033
# Only rename if old column exists
@@ -38,6 +41,9 @@ def downgrade() -> None:
3841
conn = op.get_bind()
3942
inspector = sa.inspect(conn)
4043

44+
if "resources" not in inspector.get_table_names():
45+
return
46+
4147
columns = [c["name"] for c in inspector.get_columns("resources")]
4248

4349
# Only rename back if current column exists

mcpgateway/alembic/versions/43c07ed25a24_add_oauth_fields_to_servers.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,58 @@
2424
depends_on: Union[str, Sequence[str], None] = None
2525

2626

27+
def _table_exists(table_name: str) -> bool:
28+
"""Check whether a table exists.
29+
30+
Args:
31+
table_name: Name of table to inspect.
32+
33+
Returns:
34+
True if table exists, False otherwise.
35+
"""
36+
bind = op.get_bind()
37+
inspector = sa.inspect(bind)
38+
return table_name in inspector.get_table_names()
39+
40+
41+
def _column_exists(table_name: str, column_name: str) -> bool:
42+
"""Check whether a column exists in a table.
43+
44+
Args:
45+
table_name: Name of table to inspect.
46+
column_name: Name of column to inspect.
47+
48+
Returns:
49+
True if column exists, False otherwise.
50+
"""
51+
if not _table_exists(table_name):
52+
return False
53+
bind = op.get_bind()
54+
inspector = sa.inspect(bind)
55+
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
56+
57+
2758
def upgrade() -> None:
2859
"""Add OAuth configuration fields to servers table."""
60+
if not _table_exists("servers"):
61+
return
62+
2963
# Add oauth_enabled column with default False
3064
# Use sa.false() for cross-database compatibility (SQLite, PostgreSQL)
31-
op.add_column("servers", sa.Column("oauth_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
65+
if not _column_exists("servers", "oauth_enabled"):
66+
op.add_column("servers", sa.Column("oauth_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
3267

3368
# Add oauth_config column for storing OAuth configuration as JSON
34-
op.add_column("servers", sa.Column("oauth_config", sa.JSON(), nullable=True))
69+
if not _column_exists("servers", "oauth_config"):
70+
op.add_column("servers", sa.Column("oauth_config", sa.JSON(), nullable=True))
3571

3672

3773
def downgrade() -> None:
3874
"""Remove OAuth configuration fields from servers table."""
39-
op.drop_column("servers", "oauth_config")
40-
op.drop_column("servers", "oauth_enabled")
75+
if not _table_exists("servers"):
76+
return
77+
78+
if _column_exists("servers", "oauth_config"):
79+
op.drop_column("servers", "oauth_config")
80+
if _column_exists("servers", "oauth_enabled"):
81+
op.drop_column("servers", "oauth_enabled")

mcpgateway/alembic/versions/4f07c116f917_add_indexes_for_pagination.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ def _index_exists(table_name: str, index_name: str) -> bool:
5353
return False
5454

5555

56+
def _table_exists(table_name: str) -> bool:
57+
"""Check if a table exists.
58+
59+
Args:
60+
table_name: Name of the table to check.
61+
62+
Returns:
63+
True if table exists, False otherwise.
64+
"""
65+
conn = op.get_bind()
66+
inspector = inspect(conn)
67+
try:
68+
return table_name in inspector.get_table_names()
69+
except Exception:
70+
return False
71+
72+
5673
def _create_index_safe(index_name: str, table_name: str, columns: list[str], unique: bool = False) -> bool:
5774
"""Create an index only if it doesn't already exist.
5875
@@ -65,6 +82,10 @@ def _create_index_safe(index_name: str, table_name: str, columns: list[str], uni
6582
Returns:
6683
True if index was created, False if it already existed
6784
"""
85+
if not _table_exists(table_name):
86+
print(f"⚠️ Skipping {index_name}: Table '{table_name}' does not exist")
87+
return False
88+
6889
if _index_exists(table_name, index_name):
6990
print(f"⚠️ Skipping {index_name}: Index already exists on {table_name}")
7091
return False
@@ -84,6 +105,10 @@ def _drop_index_safe(index_name: str, table_name: str) -> bool:
84105
Returns:
85106
True if index was dropped, False if it didn't exist
86107
"""
108+
if not _table_exists(table_name):
109+
print(f"⚠️ Skipping drop of {index_name}: Table '{table_name}' does not exist")
110+
return False
111+
87112
if not _index_exists(table_name, index_name):
88113
print(f"⚠️ Skipping drop of {index_name}: Index does not exist on {table_name}")
89114
return False
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# -*- coding: utf-8 -*-
2+
"""Add LLM permissions to default RBAC roles.
3+
4+
Revision ID: 9f5d93ced2b3
5+
Revises: y8i9j0k1l2m3
6+
Create Date: 2026-02-23 11:09:14.709030
7+
8+
Backfills default role permission sets so existing deployments receive:
9+
- team_admin: llm.read, llm.invoke
10+
- developer: llm.read, llm.invoke
11+
- viewer: llm.read
12+
- platform_viewer: llm.read
13+
"""
14+
15+
# Standard
16+
from datetime import datetime, timezone
17+
import json
18+
from typing import Sequence, Union
19+
20+
# Third-Party
21+
from alembic import op
22+
import sqlalchemy as sa
23+
from sqlalchemy import text
24+
25+
# revision identifiers, used by Alembic.
26+
revision: str = "9f5d93ced2b3"
27+
down_revision: Union[str, Sequence[str], None] = "y8i9j0k1l2m3"
28+
branch_labels: Union[str, Sequence[str], None] = None
29+
depends_on: Union[str, Sequence[str], None] = None
30+
31+
ROLE_PERMISSION_ADDITIONS = {
32+
"team_admin": ["llm.read", "llm.invoke"],
33+
"developer": ["llm.read", "llm.invoke"],
34+
"viewer": ["llm.read"],
35+
"platform_viewer": ["llm.read"],
36+
}
37+
38+
39+
def _load_permissions(raw_permissions: object) -> list[str]:
40+
"""Normalize stored role permissions into a list of strings.
41+
42+
Args:
43+
raw_permissions: Raw permissions value from the role row.
44+
45+
Returns:
46+
list[str]: Normalized list of permission strings.
47+
"""
48+
if not raw_permissions:
49+
return []
50+
51+
parsed = raw_permissions
52+
if isinstance(parsed, (bytes, bytearray)):
53+
parsed = parsed.decode("utf-8")
54+
55+
if isinstance(parsed, str):
56+
try:
57+
parsed = json.loads(parsed)
58+
except json.JSONDecodeError:
59+
return []
60+
61+
if isinstance(parsed, list):
62+
return [perm for perm in parsed if isinstance(perm, str)]
63+
64+
return []
65+
66+
67+
def _update_role_permissions(conn, role_name: str, permissions: list[str], add: bool) -> None:
68+
"""Add or remove permissions from a role, idempotently.
69+
70+
Args:
71+
conn: Active Alembic connection.
72+
role_name: Role name to update.
73+
permissions: Permission values to add or remove.
74+
add: When True, add permissions; when False, remove permissions.
75+
"""
76+
row = conn.execute(
77+
text("SELECT id, permissions FROM roles WHERE name = :name LIMIT 1"),
78+
{"name": role_name},
79+
).fetchone()
80+
81+
if not row:
82+
print(f"{role_name} role not found. Skipping.")
83+
return
84+
85+
role_id = row[0]
86+
current_permissions = _load_permissions(row[1])
87+
88+
if add:
89+
updated_permissions = list(current_permissions)
90+
for permission in permissions:
91+
if permission not in updated_permissions:
92+
updated_permissions.append(permission)
93+
else:
94+
updated_permissions = [permission for permission in current_permissions if permission not in permissions]
95+
96+
if updated_permissions == current_permissions:
97+
return
98+
99+
dialect_name = conn.dialect.name
100+
if dialect_name == "postgresql":
101+
update_query = text(
102+
"""
103+
UPDATE roles
104+
SET permissions = CAST(:permissions AS JSONB), updated_at = :updated_at
105+
WHERE id = :role_id
106+
"""
107+
)
108+
else:
109+
update_query = text(
110+
"""
111+
UPDATE roles
112+
SET permissions = :permissions, updated_at = :updated_at
113+
WHERE id = :role_id
114+
"""
115+
)
116+
117+
conn.execute(
118+
update_query,
119+
{
120+
"permissions": json.dumps(updated_permissions),
121+
"updated_at": datetime.now(timezone.utc),
122+
"role_id": role_id,
123+
},
124+
)
125+
126+
127+
def upgrade() -> None:
128+
"""Backfill LLM permissions into default role records."""
129+
conn = op.get_bind()
130+
inspector = sa.inspect(conn)
131+
if "roles" not in inspector.get_table_names():
132+
print("roles table not found. Skipping migration.")
133+
return
134+
135+
for role_name, permissions in ROLE_PERMISSION_ADDITIONS.items():
136+
_update_role_permissions(conn, role_name, permissions, add=True)
137+
138+
139+
def downgrade() -> None:
140+
"""Remove LLM permissions from default role records."""
141+
conn = op.get_bind()
142+
inspector = sa.inspect(conn)
143+
if "roles" not in inspector.get_table_names():
144+
return
145+
146+
for role_name, permissions in ROLE_PERMISSION_ADDITIONS.items():
147+
_update_role_permissions(conn, role_name, permissions, add=False)

0 commit comments

Comments
 (0)