Skip to content

Commit fa3a5b9

Browse files
fylornclaude
andauthored
fix(rbac): require gateway use, and count only grants that give it (#70)
The AI gateway never checked `ai_gateway:use` (nor the MCP gateway `mcp_gateway:use`), and a role that did not grant it counted as "unrestricted" when model and tool scopes were merged. So a user with no role, or only `viewer`, could call every model and tool, and adding `viewer` to a user limited to one model opened all of them. A role granted at team scope also contributed its model and tool access to every request, platform-wide. - The gateway middleware refuses with 403 (naming the missing permission, in the caller's error format) unless a role of the key's owner grants the surface's `*_gateway:use`; an explicit Deny on the action closes it. An ownerless key gets nothing. - Model / tool scopes are the union over roles that grant the surface; a role that does not contributes nothing. Action wildcards (`ai_gateway:*`, `*`) count. - Gateway decisions read global assignments and roles attached to the user's teams; team-scoped assignments administer the team only. - An empty model allow-list allows nothing (the MCP side already did), and the key/role intersection is pattern-aware so a key narrowed inside a prefix grant keeps working. - The console's effective-permissions preview follows the same rules, and the role assignment editor says a team-scoped role grants no gateway access. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9afcc08 commit fa3a5b9

11 files changed

Lines changed: 1062 additions & 177 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,59 @@ target.
1111

1212
## [Unreleased]
1313

14+
### Read before upgrading
15+
16+
- **The gateways now require `ai_gateway:use` / `mcp_gateway:use`.** A
17+
request to the AI gateway is refused with `403` unless a role held by
18+
the key's owner grants `ai_gateway:use`, and a request to the MCP
19+
gateway unless one grants `mcp_gateway:use`. The body names the
20+
missing permission. Users with no role at all, and users whose only
21+
roles are `viewer` or custom roles without these actions, used to
22+
reach every model and tool and are now refused. The built-in
23+
`developer`, `admin`, `super_admin` and `team_manager` roles grant
24+
both, so users holding one of them at global scope, or through a role
25+
attached to one of their teams, keep working. Before upgrading, list
26+
gateway users without such a role and give them `developer` (or a
27+
custom role with the actions). If SSO users are meant to use the
28+
gateway on first sign-in, set *Default Role for New Users*
29+
(`auth.default_role`) in Settings to `developer`; it is empty by
30+
default.
31+
- **A role that does not grant gateway use no longer widens model or
32+
tool access.** Model and MCP tool scopes are now the union over the
33+
roles that grant `ai_gateway:use` / `mcp_gateway:use` only. A role
34+
without those actions (such as `viewer`) used to count as
35+
"unrestricted", so adding it to a user limited to some models opened
36+
every model to them. Users relying on that lose the extra models.
37+
- **A role granted at team scope no longer grants gateway access.** An
38+
assignment with scope `team:<id>` administers that team from the
39+
console (team roster, team limits); it no longer contributes models,
40+
MCP tools or gateway use, which it used to do for every request the
41+
user made, member of the team or not. Roles attached to a team itself
42+
(Teams → Roles), which every member inherits, still count. A user
43+
whose only gateway role was a team-scoped `developer` or
44+
`team_manager` needs that role at global scope, or attached to their
45+
team.
46+
- **An empty model allow-list allows nothing.** A key whose
47+
`allowed_models` is `[]`, or whose list shares no model with what its
48+
owner's roles grant, used to call any model; it now calls none, as
49+
`allowed_mcp_tools: []` already did on the MCP gateway. The console
50+
never saves `[]` (clearing the picker sends `null`), so only keys
51+
written through the API are affected. Model entries still match by
52+
prefix, and a key narrowed to `gpt-4o-mini` under a role granting
53+
`gpt-4o` keeps `gpt-4o-mini`.
54+
55+
### Security
56+
57+
- **Gateway use is checked, and a missing grant means no access.** See
58+
the first three items above: a user with no role, or only roles
59+
without gateway use, could call every model and MCP tool; a
60+
`viewer`-style role widened a restricted user to every model; a role
61+
granted at the scope of any team, even one the user was not a member
62+
of, widened model and tool access platform-wide. An explicit `Deny`
63+
on `ai_gateway:use` or `mcp_gateway:use` now closes that gateway.
64+
Action wildcards (`ai_gateway:*`, `*`) grant it, as they already did
65+
for console permissions.
66+
1467
## [2.1.0] — 2026-09-30
1568

1669
Amazon Bedrock becomes a provider you can run from the console. It

‎crates/auth/src/rbac.rs‎

Lines changed: 210 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ use uuid::Uuid;
99
// permissions, model/tool scopes, rate limits, and budgets.
1010
//
1111
// Permissions (Allow actions): UNION across roles — most permissive.
12-
// Model scope: UNION; if any role has Resource:"*" → unrestricted.
12+
// Model / MCP tool scope: UNION over the roles that grant
13+
// `ai_gateway:use` / `mcp_gateway:use`; a role that grants neither
14+
// contributes nothing, and only global and team-inherited roles count
15+
// (a role granted at team scope administers that team only).
1316
// Rate limits: per (metric, window) take MIN MaxCount — most restrictive.
1417
// Budgets: per Period take MIN MaxTokens — most restrictive.
1518
// Deny statements: win over Allow across all roles.
@@ -69,6 +72,36 @@ async fn load_user_policy_documents(
6972
Ok(rows.into_iter().map(|(v,)| v).collect())
7073
}
7174

75+
/// The policy documents that decide gateway access for `user_id`:
76+
/// roles assigned at global scope, plus roles attached to a team the
77+
/// user is a member of (a team's roles are its members' working roles).
78+
///
79+
/// Roles granted at `scope_kind = 'team'` are left out. Such a grant
80+
/// lets the holder administer that team from the console; gateway
81+
/// requests carry no team, so honouring it here would turn a team
82+
/// grant into platform-wide model and tool access.
83+
async fn load_gateway_policy_documents(
84+
pool: &PgPool,
85+
user_id: Uuid,
86+
) -> Result<Vec<serde_json::Value>, sqlx::Error> {
87+
let rows: Vec<(serde_json::Value,)> = sqlx::query_as(
88+
"SELECT DISTINCT r.policy_document FROM ( \
89+
SELECT ra.role_id FROM rbac_role_assignments ra \
90+
WHERE ra.user_id = $1 AND ra.scope_kind = 'global' \
91+
UNION \
92+
SELECT tra.role_id \
93+
FROM team_members tm \
94+
JOIN team_role_assignments tra ON tra.team_id = tm.team_id \
95+
WHERE tm.user_id = $1 \
96+
) roles \
97+
JOIN rbac_roles r ON r.id = roles.role_id",
98+
)
99+
.bind(user_id)
100+
.fetch_all(pool)
101+
.await?;
102+
Ok(rows.into_iter().map(|(v,)| v).collect())
103+
}
104+
72105
/// Load the list of role NAMES (system + custom) assigned to `user_id`,
73106
/// including roles inherited through team membership.
74107
/// Used by the UI for badges and by `claims.roles`.
@@ -157,63 +190,124 @@ pub async fn compute_user_role_assignments(
157190
.collect())
158191
}
159192

160-
/// Effective resource constraints for a user, derived by union'ing
161-
/// every role's model and MCP tool scopes from their policy_documents.
193+
/// Effective gateway access for a user, derived from the roles that
194+
/// decide it (see [`load_gateway_policy_documents`]).
162195
///
163-
/// - If ANY role has `Resource: "*"` on the relevant gateway
164-
/// statement, the field in the result is `None` (unrestricted).
165-
/// - Otherwise the result is the union of every role's scoped
166-
/// resources, deduplicated.
196+
/// - `ai_gateway` / `mcp_gateway` is true when some role grants
197+
/// `ai_gateway:use` / `mcp_gateway:use` and no role denies it.
198+
/// - Only roles that grant a surface contribute resources to it. If
199+
/// one of them has `Resource: "*"`, the list is `None`
200+
/// (unrestricted); otherwise it is the union of their scoped
201+
/// resources.
202+
/// - A surface that is not granted has an empty list (`Some([])`),
203+
/// never `None`, so a caller that forgets the flag still allows
204+
/// nothing.
167205
///
168206
/// This is what the gateway middleware merges with the per-API-key
169207
/// allow-list (if any) before calling into the proxy.
208+
#[derive(Debug, Clone, PartialEq, Eq)]
170209
pub struct UserResourceLimits {
210+
pub ai_gateway: bool,
171211
pub allowed_models: Option<Vec<String>>,
212+
pub mcp_gateway: bool,
172213
/// MCP tool patterns: `None` = unrestricted, `["mysql__*"]` = server
173214
/// wildcard, `["mysql__query"]` = exact tool.
174215
pub allowed_mcp_tools: Option<Vec<String>>,
175216
}
176217

218+
impl UserResourceLimits {
219+
/// Neither gateway, nothing on either.
220+
pub fn none() -> Self {
221+
Self {
222+
ai_gateway: false,
223+
allowed_models: Some(Vec::new()),
224+
mcp_gateway: false,
225+
allowed_mcp_tools: Some(Vec::new()),
226+
}
227+
}
228+
}
229+
177230
pub async fn compute_user_resource_limits(
178231
pool: &PgPool,
179232
user_id: Uuid,
180233
) -> Result<UserResourceLimits, sqlx::Error> {
181-
let docs = load_user_policy_documents(pool, user_id).await?;
182-
if docs.is_empty() {
183-
return Ok(UserResourceLimits {
184-
allowed_models: None,
185-
allowed_mcp_tools: None,
186-
});
187-
}
234+
let docs = load_gateway_policy_documents(pool, user_id).await?;
235+
Ok(resource_limits_from_documents(&docs))
236+
}
188237

189-
let mut models_unrestricted = false;
190-
let mut tools_unrestricted = false;
191-
let mut models: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
192-
let mut tools: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
238+
/// One gateway surface, folded across roles.
239+
struct SurfaceGrant {
240+
granted: bool,
241+
unrestricted: bool,
242+
items: std::collections::BTreeSet<String>,
243+
}
193244

194-
for doc in &docs {
195-
match think_watch_common::limits::extract_allowed_models(doc) {
196-
None => models_unrestricted = true,
197-
Some(list) => models.extend(list),
245+
impl SurfaceGrant {
246+
fn new() -> Self {
247+
Self {
248+
granted: false,
249+
unrestricted: false,
250+
items: std::collections::BTreeSet::new(),
198251
}
199-
match think_watch_common::limits::extract_allowed_mcp_tools(doc) {
200-
None => tools_unrestricted = true,
201-
Some(list) => tools.extend(list),
252+
}
253+
254+
fn add(&mut self, scope: think_watch_common::limits::ResourceScope) {
255+
use think_watch_common::limits::ResourceScope;
256+
match scope {
257+
ResourceScope::NotGranted => {}
258+
ResourceScope::All => {
259+
self.granted = true;
260+
self.unrestricted = true;
261+
}
262+
ResourceScope::Only(list) => {
263+
self.granted = true;
264+
self.items.extend(list);
265+
}
202266
}
203267
}
204268

205-
Ok(UserResourceLimits {
206-
allowed_models: if models_unrestricted {
207-
None
208-
} else {
209-
Some(models.into_iter().collect())
210-
},
211-
allowed_mcp_tools: if tools_unrestricted {
212-
None
269+
fn finish(self, denied: bool) -> (bool, Option<Vec<String>>) {
270+
if !self.granted || denied {
271+
(false, Some(Vec::new()))
272+
} else if self.unrestricted {
273+
(true, None)
213274
} else {
214-
Some(tools.into_iter().collect())
215-
},
216-
})
275+
(true, Some(self.items.into_iter().collect()))
276+
}
277+
}
278+
}
279+
280+
fn resource_limits_from_documents(docs: &[serde_json::Value]) -> UserResourceLimits {
281+
use think_watch_common::limits::{extract_mcp_tool_scope, extract_model_scope};
282+
283+
let mut models = SurfaceGrant::new();
284+
let mut tools = SurfaceGrant::new();
285+
for doc in docs {
286+
models.add(extract_model_scope(doc));
287+
tools.add(extract_mcp_tool_scope(doc));
288+
}
289+
290+
// An explicit Deny on the whole action, in any of these roles, closes
291+
// the surface — the same rule `compute_denied_permissions` applies to
292+
// console permissions.
293+
let policies: Vec<PolicyDocument> = docs
294+
.iter()
295+
.filter_map(|v| serde_json::from_value(v.clone()).ok())
296+
.collect();
297+
let denied = |action: &str| {
298+
policies
299+
.iter()
300+
.any(|doc| evaluate_policy(doc, action, "*") == PolicyResult::Deny)
301+
};
302+
303+
let (ai_gateway, allowed_models) = models.finish(denied("ai_gateway:use"));
304+
let (mcp_gateway, allowed_mcp_tools) = tools.finish(denied("mcp_gateway:use"));
305+
UserResourceLimits {
306+
ai_gateway,
307+
allowed_models,
308+
mcp_gateway,
309+
allowed_mcp_tools,
310+
}
217311
}
218312

219313
/// Role-only merged surface constraints — the baseline before any
@@ -336,19 +430,6 @@ pub async fn compute_effective_surface_constraints(
336430
Ok(apply_user_overrides(user_merged, key_overrides))
337431
}
338432

339-
/// Check if a namespaced MCP tool name matches any of the allowed patterns.
340-
/// Patterns: `"*"` matches all, `"mysql__*"` matches prefix, exact otherwise.
341-
pub fn is_mcp_tool_allowed(patterns: Option<&[String]>, namespaced_name: &str) -> bool {
342-
match patterns {
343-
None => true, // NULL = unrestricted
344-
Some(pats) => pats.iter().any(|p| {
345-
p == "*"
346-
|| (p.ends_with("__*") && namespaced_name.starts_with(&p[..p.len() - 1]))
347-
|| p == namespaced_name
348-
}),
349-
}
350-
}
351-
352433
/// Compute the set of permissions that are explicitly denied to `user_id`
353434
/// by policy documents attached to any of their roles (direct + team).
354435
///
@@ -848,4 +929,84 @@ mod tests {
848929
PolicyResult::Deny
849930
);
850931
}
932+
933+
// --- Gateway resource limits ---
934+
935+
fn doc(statement: serde_json::Value) -> serde_json::Value {
936+
serde_json::json!({"Version": "2024-01-01", "Statement": [statement]})
937+
}
938+
939+
fn viewer() -> serde_json::Value {
940+
doc(serde_json::json!({
941+
"Effect": "Allow",
942+
"Action": ["api_keys:read", "providers:read", "models:read", "mcp_servers:read", "analytics:read_own"],
943+
"Resource": "*"
944+
}))
945+
}
946+
947+
fn developer() -> serde_json::Value {
948+
doc(serde_json::json!({
949+
"Effect": "Allow",
950+
"Action": ["ai_gateway:use", "mcp_gateway:use", "api_keys:read"],
951+
"Resource": "*"
952+
}))
953+
}
954+
955+
#[test]
956+
fn no_roles_grant_nothing() {
957+
assert_eq!(
958+
resource_limits_from_documents(&[]),
959+
UserResourceLimits::none()
960+
);
961+
}
962+
963+
#[test]
964+
fn a_role_without_gateway_use_grants_nothing() {
965+
assert_eq!(
966+
resource_limits_from_documents(&[viewer()]),
967+
UserResourceLimits::none()
968+
);
969+
}
970+
971+
#[test]
972+
fn a_role_without_gateway_use_does_not_widen_another() {
973+
let only_a = doc(serde_json::json!({
974+
"Effect": "Allow",
975+
"Action": ["ai_gateway:use"],
976+
"Resource": ["model:model-a"]
977+
}));
978+
let limits = resource_limits_from_documents(&[only_a, viewer()]);
979+
assert!(limits.ai_gateway);
980+
assert_eq!(limits.allowed_models, Some(vec!["model-a".to_string()]));
981+
assert!(!limits.mcp_gateway);
982+
assert_eq!(limits.allowed_mcp_tools, Some(vec![]));
983+
}
984+
985+
#[test]
986+
fn a_gateway_role_with_resource_star_is_unrestricted() {
987+
let limits = resource_limits_from_documents(&[developer(), viewer()]);
988+
assert_eq!(
989+
limits,
990+
UserResourceLimits {
991+
ai_gateway: true,
992+
allowed_models: None,
993+
mcp_gateway: true,
994+
allowed_mcp_tools: None,
995+
}
996+
);
997+
}
998+
999+
#[test]
1000+
fn a_deny_on_the_action_closes_the_surface() {
1001+
let deny = doc(serde_json::json!({
1002+
"Effect": "Deny",
1003+
"Action": "ai_gateway:use",
1004+
"Resource": "*"
1005+
}));
1006+
let limits = resource_limits_from_documents(&[developer(), deny]);
1007+
assert!(!limits.ai_gateway);
1008+
assert_eq!(limits.allowed_models, Some(vec![]));
1009+
assert!(limits.mcp_gateway);
1010+
assert_eq!(limits.allowed_mcp_tools, None);
1011+
}
8511012
}

0 commit comments

Comments
 (0)