@@ -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 ) ]
170209pub 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+
177230pub 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