diff --git a/CHANGELOG.md b/CHANGELOG.md index 4089826a..9a7aad20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,197 @@ target. ## [Unreleased] +## [2.2.0] — 2026-10-01 + +This release fixes authorization. The gateways never checked +`ai_gateway:use` or `mcp_gateway:use`, so a user with no role, or with +only roles that grant neither, could call every model and MCP tool; a +role granted at the scope of one team widened model and tool access +platform-wide; and an API key whose allow-list came out empty could +call any model. The gateways now require the permission and count only +the roles that grant it. The seeded `team_manager` role now works when +granted at team scope, and seven permissions that nothing ever checked +are retired. Some users lose gateway access on upgrade: read the first +section before deploying. There is one manual database script and no +schema, setting, environment variable or Helm value change. + +### Read before upgrading + +- **Users without a role that grants gateway use lose gateway access.** + A request to the AI gateway is refused with `403` unless a role held + by the key's owner grants `ai_gateway:use`, and a request to the MCP + gateway unless one grants `mcp_gateway:use`. The body names the + missing permission, in the error format of the caller's API. The + built-in `developer`, `admin`, `super_admin` and `team_manager` roles + grant both; `viewer` grants neither. Three groups of users are + refused after the upgrade where 2.1.0 let them through: + - users with no role at all; + - users whose roles are only `viewer`, or custom roles without these + actions; + - users whose only gateway role is assigned at team scope (see the + third item below). + + Find them before upgrading. This query lists every active user with a + live key for a gateway that none of their roles will grant (it counts + global assignments and roles attached to the user's teams, which is + what the gateways now read; it does not account for `Deny` + statements): + + ```sql + WITH grants AS ( + SELECT id AS role_id, + jsonb_path_exists(policy_document, + '$.Statement[*] ? (@.Effect == "Allow").Action[*] ? (@ == "*" || @ == "ai_gateway:*" || @ == "ai_gateway:use")') AS ai, + jsonb_path_exists(policy_document, + '$.Statement[*] ? (@.Effect == "Allow").Action[*] ? (@ == "*" || @ == "mcp_gateway:*" || @ == "mcp_gateway:use")') AS mcp + FROM rbac_roles + ), + held AS ( + SELECT user_id, role_id FROM rbac_role_assignments WHERE scope_kind = 'global' + UNION + SELECT tm.user_id, tra.role_id + FROM team_members tm JOIN team_role_assignments tra USING (team_id) + ), + access AS ( + SELECT h.user_id, bool_or(g.ai) AS ai, bool_or(g.mcp) AS mcp + FROM held h JOIN grants g USING (role_id) GROUP BY h.user_id + ) + SELECT u.email, s.surface + FROM api_keys k + JOIN users u ON u.id = k.user_id + CROSS JOIN LATERAL unnest(k.surfaces) AS s(surface) + LEFT JOIN access a ON a.user_id = u.id + WHERE k.is_active AND k.deleted_at IS NULL + AND u.is_active AND u.deleted_at IS NULL + AND NOT COALESCE(CASE s.surface WHEN 'ai_gateway' THEN a.ai + WHEN 'mcp_gateway' THEN a.mcp END, false) + GROUP BY u.email, s.surface + ORDER BY u.email, s.surface; + ``` + + Give each of them `developer` (or a custom role with the actions) + either at global scope (Users → edit the user's roles, scope + *Global*), or by attaching the role to a team they belong to (Teams → + the team → Roles), which every member of that team inherits. The + change takes effect within a minute; each user's permissions are + cached for 60 seconds. + + If SSO users are meant to use the gateway from their first sign-in, + set *Default Role for New Users* (`auth.default_role`) in Settings to + `developer`. It is empty by default, and it applies only to accounts + created after it is set; existing users need the grant above. +- **A role that does not grant gateway use no longer widens model or + tool access.** Model and MCP tool scopes are now the union over the + roles that grant `ai_gateway:use` / `mcp_gateway:use` only. A role + without those actions (such as `viewer`) used to count as + "unrestricted", so adding it to a user limited to some models opened + every model to them. Users relying on that lose the extra models. +- **A role assigned at team scope no longer grants gateway access.** An + assignment with scope `team:` administers that team from the + console (team roster, team limits); it no longer contributes models, + MCP tools or gateway use, which it used to do for every request the + user made, member of the team or not. Roles attached to a team itself + (Teams → Roles), which every member inherits, still count. A user + whose only gateway role was a team-scoped `developer` or + `team_manager` needs that role at global scope, or attached to their + team. The role assignment editor now says this under the scope + picker. +- **An empty model allow-list allows nothing.** A key whose + `allowed_models` is `[]`, or whose list shares no model with what its + owner's roles grant, used to call any model; it now calls none, as + `allowed_mcp_tools: []` already did on the MCP gateway. The console + never saves `[]` (clearing the picker sends `null`), so only keys + written through the API are affected. To find them: + `SELECT id, name, user_id FROM api_keys WHERE allowed_models = '{}' AND is_active AND deleted_at IS NULL;` + Set such a key's list to `null` to leave it bounded by its owner's + roles only. Model entries still match by prefix, and a key narrowed to + `gpt-4o-mini` under a role granting `gpt-4o` keeps `gpt-4o-mini`. +- **Run `db/release_migrations/2026-09-30_retire_unchecked_permissions.sql` + once, after deploying.** The server does not apply it. It adds + `teams:read` to the `team_manager` role and removes the seven retired + permissions (see *Changed*) from every role, system and custom. It is + idempotent and changes no access for any other role, since nothing + checked the removed keys. Without it, the server still starts and + works, but: + - `team_manager` keeps the old `team:read` it cannot use, so a team + manager who is not a member of the team they manage still gets + `403` opening it, its roster or its roles; + - every start logs a warning listing the roles that still name + retired permissions. + + *Reset to defaults* on a system role in the console has the same + effect for that one role, but does not clean custom roles. +- **A team-scoped `rate_limits:write` now takes effect.** It used to + require global scope for every subject, so the seeded `team_manager` + granted at team scope could not touch any limit. It now covers the + rate limits and budget caps of users in that team and of the API keys + they own, never the holder's own user or keys; role subjects still + need global scope. Anyone holding `team_manager` (or a custom role + with `rate_limits:write`) at team scope can now change, lift or + delete the limits of every member of that team, administrators + included. Review team-scoped assignments of these roles before + upgrading. + +### Security + +- **Gateway use is checked, and a missing grant means no access.** See + the first three items above: a user with no role, or only roles + without gateway use, could call every model and MCP tool; a + `viewer`-style role widened a restricted user to every model; a role + granted at the scope of any team, even one the user was not a member + of, widened model and tool access platform-wide. An explicit `Deny` + on `ai_gateway:use` or `mcp_gateway:use` now closes that gateway. + Action wildcards (`ai_gateway:*`, `*`) grant it, as they already did + for console permissions. +- **A key narrowed inside a prefix grant is no longer unrestricted.** + The key's allow-list was intersected with its owner's role grants + entry by entry, as literal strings. A key limited to `gpt-4o-mini` + under a role granting `gpt-4o` (a prefix) came out with an empty list, + which the gateway read as "no restriction", so the key could call + every model. The intersection now keeps an entry of either side that + the other side covers, by prefix for models and by `__*` + pattern for MCP tools, and an empty result allows nothing. + +### Fixed + +- **The seeded `team_manager` role works at team scope.** It granted + `team:read`, but the team handlers check `teams:read`, so a team + manager got `403` listing teams and opening the team, its roster or + its roles. It now grants `teams:read`, and opening a team accepts + `teams:read` scoped to that team, where it used to require global + scope. Existing installations need the release migration above. +- **Bulk disable and delete work on API keys' limits.** The bulk + disable and delete routes for rate-limit rules and budget caps + rejected every row stored for an API key (`api_key_lineage`, the kind + a key's limits are stored under) as an unknown subject kind. +- **The console's effective-permissions preview shows real gateway + access.** It now counts only roles that grant gateway use and skips + team-scoped assignments, as the gateways do, where it used to count + every assigned role. + +### Changed + +- **Seven permissions that nothing checked are retired**: `team:read`, + `team:write`, `logs:read_own`, `logs:read_team`, + `audit_logs:read_own`, `audit_logs:read_team` and + `audit_logs:read_all`. Every log endpoint, audit logs included, is + gated on `logs:read_all` at global scope, and no own- or team-filtered + log view exists, so these grants never did anything. They are gone + from the permission catalog, the role editor and the seeded roles. + Roles that still name them load, and the server logs a warning at + start instead of refusing to boot. +- **Deleting one rate-limit rule or budget cap is bound to the subject + in the path.** `DELETE` on + `/api/admin/limits/{kind}/{id}/rules/{rule_id}` and + `/api/admin/limits/{kind}/{id}/budgets/{cap_id}` now answers `404` when the rule or cap belongs to a different + subject; it used to delete any row id once the path's subject was + authorized. +- **Creating or editing a key with an explicit allow-list checks it + against gateway-granting roles only.** An `allowed_models` or + `allowed_mcp_tools` entry is refused with `400` when no role of the + owner that grants the gateway covers it, so a user without a gateway + role can no longer save a non-empty list. + ## [2.1.0] — 2026-09-30 Amazon Bedrock becomes a provider you can run from the console. It @@ -716,7 +907,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql` against PostgreSQL, restart against this tag. The schema is idempotent end-to-end, so the apply is safe to repeat. -[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v2.1.0...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v2.2.0...HEAD +[2.2.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.2.0 [2.1.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.1.0 [2.0.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.0.0 [1.1.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.1.0 diff --git a/Cargo.lock b/Cargo.lock index 4a72d63e..70ec9fd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4034,7 +4034,7 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "think-watch-auth" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "argon2", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "2.1.0" +version = "2.2.0" dependencies = [ "aes-gcm", "anyhow", @@ -4102,7 +4102,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "arc-swap", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "arc-swap", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "arc-swap", @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index c938d52e..f61d13da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "2.1.0" +version = "2.2.0" edition = "2024" # Pin the MSRV to the first stable rustc that ships edition 2024 (1.85, # released 2025-02-20). Without this, contributors on older toolchains diff --git a/README.md b/README.md index bb997af7..274a7dc2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ - **One key for AI and MCP.** Users receive `tw-` virtual keys that can be scoped to the AI gateway, the MCP gateway or both. Keys are stored only as hashes and rotate with a grace period. - **Rate limits and budgets.** Sliding windows from one minute to one week limit requests or tokens, and daily, weekly or monthly budgets cap spending. Both attach to users, API keys or roles, and rate limits apply to MCP tool calls as well as model requests. - **Cost accounting that finance can use.** Spend is reported by model, user, provider and cost center, with CSV chargeback reports and a month-end forecast. Per-model weights make expensive models count for more against the same quota. -- **Audit trail in ClickHouse.** Every model request and tool call is recorded with user, parameters, response, latency and errors, with PII redacted before storage. Events can be forwarded to a SIEM over Syslog, Kafka (through a REST proxy) or signed webhooks. +- **Audit trail in ClickHouse.** Every model request and tool call is recorded with user, parameters, response, latency and errors, and request bodies can be PII-redacted before storage (off by default). Events can be forwarded to a SIEM over Syslog, Kafka (through a REST proxy) or signed webhooks. - **One endpoint for every client.** OpenAI Chat Completions, OpenAI Responses, Anthropic Messages and Gemini requests are served on one port and converted to whatever the upstream speaks. Routing spreads traffic by weight, latency or health, and a circuit breaker takes failing upstreams out of rotation. ## Quick start @@ -60,7 +60,7 @@ cd web && pnpm install && pnpm dev # 4. Complete the setup wizard at http://localhost:5173/setup ``` -The setup wizard creates the first Super Admin account and can add the first provider and API key. The console then has copy-paste setup instructions for Claude Code, Cursor, Continue, Cline, the OpenAI and Anthropic SDKs, and cURL. +The setup wizard creates the first Super Admin account, sets the site name and issues a first API key; providers are added in the console afterwards. The console then has copy-paste setup instructions for Claude Code, Cursor, Continue, Cline, the OpenAI and Anthropic SDKs, and cURL. ## Deployment @@ -75,7 +75,7 @@ The gateway (port `3000`) is the only part that clients need to reach. The conso **MCP identity** - A user may connect several accounts to one server (work and personal, for example) and pin each `tw-` key to one of them. -- Adding a server takes its URL: the gateway discovers the OAuth endpoints and registers itself when the upstream supports dynamic client registration. The MCP Store ships 36 ready-made templates. +- Adding a server takes its URL: the gateway discovers the OAuth endpoints and registers itself when the upstream supports dynamic client registration. The MCP Store ships 37 ready-made templates. - A user who has not yet connected an account still sees the tool list; calling a tool returns JSON-RPC error `-32050` with the authorization URL, which compliant MCP clients can show. - Responses from servers that use per-user credentials are cached per user and account, never shared. diff --git a/README.zh-CN.md b/README.zh-CN.md index c19fcbf1..35c7edcb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ - **AI 与 MCP 共用一把密钥。** 用户获得 `tw-` 虚拟密钥,可限定用于 AI 网关、MCP 网关或两者。密钥只以哈希形式保存,轮换时保留宽限期。 - **限流与预算。** 从一分钟到一周的滑动窗口可限制请求数或 Token 数,按日、周、月的预算控制总用量。两者均可设置在用户、API Key 或角色上,限流同样适用于 MCP 工具调用。 - **可用于财务核算的费用统计。** 费用可按模型、用户、上游和成本中心汇总,支持导出 CSV 分摊报表,并给出月末费用预测。按模型设置的权重使价格较高的模型在同一配额中计入更多用量。 -- **审计记录存入 ClickHouse。** 每一次模型请求和工具调用都记录用户、参数、响应、延迟与错误,个人信息在写入前即已脱敏。审计事件可通过 Syslog、Kafka(经 REST 代理)或签名 Webhook 转发至 SIEM。 +- **审计记录存入 ClickHouse。** 每一次模型请求和工具调用都记录用户、参数、响应、延迟与错误,请求正文可在写入前做个人信息脱敏(默认关闭)。审计事件可通过 Syslog、Kafka(经 REST 代理)或签名 Webhook 转发至 SIEM。 - **所有客户端共用一个入口。** OpenAI Chat Completions、OpenAI Responses、Anthropic Messages 与 Gemini 请求在同一端口提供,并转换为上游所用的格式。路由可按权重、延迟或健康状况分配流量,熔断器会将持续出错的上游移出轮转。 ## 快速开始 @@ -60,7 +60,7 @@ cd web && pnpm install && pnpm dev # 4. 在 http://localhost:5173/setup 完成设置向导 ``` -设置向导会创建首个超级管理员账号,并可添加首个上游和 API Key。此后控制台提供 Claude Code、Cursor、Continue、Cline、OpenAI 与 Anthropic SDK 以及 cURL 的配置说明,可直接复制使用。 +设置向导会创建首个超级管理员账号、设置站点名称并签发第一把 API Key;上游在之后的控制台中添加。此后控制台提供 Claude Code、Cursor、Continue、Cline、OpenAI 与 Anthropic SDK 以及 cURL 的配置说明,可直接复制使用。 ## 部署方式 @@ -75,7 +75,7 @@ cd web && pnpm install && pnpm dev **MCP 身份** - 同一用户可为一个服务器连接多个账号(例如工作账号与个人账号),并将每把 `tw-` 密钥固定到其中一个。 -- 添加服务器只需填写地址:网关自动发现 OAuth 端点,上游支持动态客户端注册时自动完成注册。MCP 应用商店内置 36 个现成模板。 +- 添加服务器只需填写地址:网关自动发现 OAuth 端点,上游支持动态客户端注册时自动完成注册。MCP 应用商店内置 37 个现成模板。 - 尚未连接账号的用户仍能看到工具列表;调用工具时返回 JSON-RPC 错误 `-32050` 并附带授权地址,符合规范的 MCP 客户端可据此引导授权。 - 使用每用户凭据的服务器,其响应按用户和账号分别缓存,不会共用。 diff --git a/crates/auth/src/rbac.rs b/crates/auth/src/rbac.rs index a9f1e165..4adac13e 100644 --- a/crates/auth/src/rbac.rs +++ b/crates/auth/src/rbac.rs @@ -9,7 +9,10 @@ use uuid::Uuid; // permissions, model/tool scopes, rate limits, and budgets. // // Permissions (Allow actions): UNION across roles — most permissive. -// Model scope: UNION; if any role has Resource:"*" → unrestricted. +// Model / MCP tool scope: UNION over the roles that grant +// `ai_gateway:use` / `mcp_gateway:use`; a role that grants neither +// contributes nothing, and only global and team-inherited roles count +// (a role granted at team scope administers that team only). // Rate limits: per (metric, window) take MIN MaxCount — most restrictive. // Budgets: per Period take MIN MaxTokens — most restrictive. // Deny statements: win over Allow across all roles. @@ -69,6 +72,36 @@ async fn load_user_policy_documents( Ok(rows.into_iter().map(|(v,)| v).collect()) } +/// The policy documents that decide gateway access for `user_id`: +/// roles assigned at global scope, plus roles attached to a team the +/// user is a member of (a team's roles are its members' working roles). +/// +/// Roles granted at `scope_kind = 'team'` are left out. Such a grant +/// lets the holder administer that team from the console; gateway +/// requests carry no team, so honouring it here would turn a team +/// grant into platform-wide model and tool access. +async fn load_gateway_policy_documents( + pool: &PgPool, + user_id: Uuid, +) -> Result, sqlx::Error> { + let rows: Vec<(serde_json::Value,)> = sqlx::query_as( + "SELECT DISTINCT r.policy_document FROM ( \ + SELECT ra.role_id FROM rbac_role_assignments ra \ + WHERE ra.user_id = $1 AND ra.scope_kind = 'global' \ + UNION \ + SELECT tra.role_id \ + FROM team_members tm \ + JOIN team_role_assignments tra ON tra.team_id = tm.team_id \ + WHERE tm.user_id = $1 \ + ) roles \ + JOIN rbac_roles r ON r.id = roles.role_id", + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(v,)| v).collect()) +} + /// Load the list of role NAMES (system + custom) assigned to `user_id`, /// including roles inherited through team membership. /// Used by the UI for badges and by `claims.roles`. @@ -157,63 +190,124 @@ pub async fn compute_user_role_assignments( .collect()) } -/// Effective resource constraints for a user, derived by union'ing -/// every role's model and MCP tool scopes from their policy_documents. +/// Effective gateway access for a user, derived from the roles that +/// decide it (see [`load_gateway_policy_documents`]). /// -/// - If ANY role has `Resource: "*"` on the relevant gateway -/// statement, the field in the result is `None` (unrestricted). -/// - Otherwise the result is the union of every role's scoped -/// resources, deduplicated. +/// - `ai_gateway` / `mcp_gateway` is true when some role grants +/// `ai_gateway:use` / `mcp_gateway:use` and no role denies it. +/// - Only roles that grant a surface contribute resources to it. If +/// one of them has `Resource: "*"`, the list is `None` +/// (unrestricted); otherwise it is the union of their scoped +/// resources. +/// - A surface that is not granted has an empty list (`Some([])`), +/// never `None`, so a caller that forgets the flag still allows +/// nothing. /// /// This is what the gateway middleware merges with the per-API-key /// allow-list (if any) before calling into the proxy. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct UserResourceLimits { + pub ai_gateway: bool, pub allowed_models: Option>, + pub mcp_gateway: bool, /// MCP tool patterns: `None` = unrestricted, `["mysql__*"]` = server /// wildcard, `["mysql__query"]` = exact tool. pub allowed_mcp_tools: Option>, } +impl UserResourceLimits { + /// Neither gateway, nothing on either. + pub fn none() -> Self { + Self { + ai_gateway: false, + allowed_models: Some(Vec::new()), + mcp_gateway: false, + allowed_mcp_tools: Some(Vec::new()), + } + } +} + pub async fn compute_user_resource_limits( pool: &PgPool, user_id: Uuid, ) -> Result { - let docs = load_user_policy_documents(pool, user_id).await?; - if docs.is_empty() { - return Ok(UserResourceLimits { - allowed_models: None, - allowed_mcp_tools: None, - }); - } + let docs = load_gateway_policy_documents(pool, user_id).await?; + Ok(resource_limits_from_documents(&docs)) +} - let mut models_unrestricted = false; - let mut tools_unrestricted = false; - let mut models: std::collections::BTreeSet = std::collections::BTreeSet::new(); - let mut tools: std::collections::BTreeSet = std::collections::BTreeSet::new(); +/// One gateway surface, folded across roles. +struct SurfaceGrant { + granted: bool, + unrestricted: bool, + items: std::collections::BTreeSet, +} - for doc in &docs { - match think_watch_common::limits::extract_allowed_models(doc) { - None => models_unrestricted = true, - Some(list) => models.extend(list), +impl SurfaceGrant { + fn new() -> Self { + Self { + granted: false, + unrestricted: false, + items: std::collections::BTreeSet::new(), } - match think_watch_common::limits::extract_allowed_mcp_tools(doc) { - None => tools_unrestricted = true, - Some(list) => tools.extend(list), + } + + fn add(&mut self, scope: think_watch_common::limits::ResourceScope) { + use think_watch_common::limits::ResourceScope; + match scope { + ResourceScope::NotGranted => {} + ResourceScope::All => { + self.granted = true; + self.unrestricted = true; + } + ResourceScope::Only(list) => { + self.granted = true; + self.items.extend(list); + } } } - Ok(UserResourceLimits { - allowed_models: if models_unrestricted { - None - } else { - Some(models.into_iter().collect()) - }, - allowed_mcp_tools: if tools_unrestricted { - None + fn finish(self, denied: bool) -> (bool, Option>) { + if !self.granted || denied { + (false, Some(Vec::new())) + } else if self.unrestricted { + (true, None) } else { - Some(tools.into_iter().collect()) - }, - }) + (true, Some(self.items.into_iter().collect())) + } + } +} + +fn resource_limits_from_documents(docs: &[serde_json::Value]) -> UserResourceLimits { + use think_watch_common::limits::{extract_mcp_tool_scope, extract_model_scope}; + + let mut models = SurfaceGrant::new(); + let mut tools = SurfaceGrant::new(); + for doc in docs { + models.add(extract_model_scope(doc)); + tools.add(extract_mcp_tool_scope(doc)); + } + + // An explicit Deny on the whole action, in any of these roles, closes + // the surface — the same rule `compute_denied_permissions` applies to + // console permissions. + let policies: Vec = docs + .iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect(); + let denied = |action: &str| { + policies + .iter() + .any(|doc| evaluate_policy(doc, action, "*") == PolicyResult::Deny) + }; + + let (ai_gateway, allowed_models) = models.finish(denied("ai_gateway:use")); + let (mcp_gateway, allowed_mcp_tools) = tools.finish(denied("mcp_gateway:use")); + UserResourceLimits { + ai_gateway, + allowed_models, + mcp_gateway, + allowed_mcp_tools, + } } /// Role-only merged surface constraints — the baseline before any @@ -336,19 +430,6 @@ pub async fn compute_effective_surface_constraints( Ok(apply_user_overrides(user_merged, key_overrides)) } -/// Check if a namespaced MCP tool name matches any of the allowed patterns. -/// Patterns: `"*"` matches all, `"mysql__*"` matches prefix, exact otherwise. -pub fn is_mcp_tool_allowed(patterns: Option<&[String]>, namespaced_name: &str) -> bool { - match patterns { - None => true, // NULL = unrestricted - Some(pats) => pats.iter().any(|p| { - p == "*" - || (p.ends_with("__*") && namespaced_name.starts_with(&p[..p.len() - 1])) - || p == namespaced_name - }), - } -} - /// Compute the set of permissions that are explicitly denied to `user_id` /// by policy documents attached to any of their roles (direct + team). /// @@ -848,4 +929,84 @@ mod tests { PolicyResult::Deny ); } + + // --- Gateway resource limits --- + + fn doc(statement: serde_json::Value) -> serde_json::Value { + serde_json::json!({"Version": "2024-01-01", "Statement": [statement]}) + } + + fn viewer() -> serde_json::Value { + doc(serde_json::json!({ + "Effect": "Allow", + "Action": ["api_keys:read", "providers:read", "models:read", "mcp_servers:read", "analytics:read_own"], + "Resource": "*" + })) + } + + fn developer() -> serde_json::Value { + doc(serde_json::json!({ + "Effect": "Allow", + "Action": ["ai_gateway:use", "mcp_gateway:use", "api_keys:read"], + "Resource": "*" + })) + } + + #[test] + fn no_roles_grant_nothing() { + assert_eq!( + resource_limits_from_documents(&[]), + UserResourceLimits::none() + ); + } + + #[test] + fn a_role_without_gateway_use_grants_nothing() { + assert_eq!( + resource_limits_from_documents(&[viewer()]), + UserResourceLimits::none() + ); + } + + #[test] + fn a_role_without_gateway_use_does_not_widen_another() { + let only_a = doc(serde_json::json!({ + "Effect": "Allow", + "Action": ["ai_gateway:use"], + "Resource": ["model:model-a"] + })); + let limits = resource_limits_from_documents(&[only_a, viewer()]); + assert!(limits.ai_gateway); + assert_eq!(limits.allowed_models, Some(vec!["model-a".to_string()])); + assert!(!limits.mcp_gateway); + assert_eq!(limits.allowed_mcp_tools, Some(vec![])); + } + + #[test] + fn a_gateway_role_with_resource_star_is_unrestricted() { + let limits = resource_limits_from_documents(&[developer(), viewer()]); + assert_eq!( + limits, + UserResourceLimits { + ai_gateway: true, + allowed_models: None, + mcp_gateway: true, + allowed_mcp_tools: None, + } + ); + } + + #[test] + fn a_deny_on_the_action_closes_the_surface() { + let deny = doc(serde_json::json!({ + "Effect": "Deny", + "Action": "ai_gateway:use", + "Resource": "*" + })); + let limits = resource_limits_from_documents(&[developer(), deny]); + assert!(!limits.ai_gateway); + assert_eq!(limits.allowed_models, Some(vec![])); + assert!(limits.mcp_gateway); + assert_eq!(limits.allowed_mcp_tools, None); + } } diff --git a/crates/common/src/limits/mod.rs b/crates/common/src/limits/mod.rs index 7c39b9a5..9e748422 100644 --- a/crates/common/src/limits/mod.rs +++ b/crates/common/src/limits/mod.rs @@ -351,48 +351,40 @@ pub fn extract_surface_constraints(doc: &serde_json::Value) -> SurfaceConstraint } } -/// Extract the effective model scope from a parsed PolicyDocument. -/// Looks at Allow statements whose Action matches `ai_gateway:use` and -/// collects Resource entries that start with `model:`. Returns `None` -/// when any matching statement has Resource `"*"` (unrestricted). -pub fn extract_allowed_models(doc: &serde_json::Value) -> Option> { - let statements = doc.get("Statement").and_then(|s| s.as_array())?; - let mut models: std::collections::BTreeSet = std::collections::BTreeSet::new(); - let mut found_any = false; +/// What one policy document grants on one gateway surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceScope { + /// No Allow statement grants the surface's `*_gateway:use` action. + /// The role contributes nothing to that gateway. + NotGranted, + /// Granted on every resource (`Resource: "*"`). + All, + /// Granted on these resources only (the `model:` / `mcp_tool:` + /// prefix stripped). May be empty: the action is granted but on no + /// resource of this kind. + Only(Vec), +} - for stmt in statements { - let effect = stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or(""); - if effect != "Allow" { - continue; - } - let actions = stmt_actions(stmt); - if !action_matches_any(&actions, "ai_gateway:use") { - continue; - } - found_any = true; - let resources = stmt_resources(stmt); - for r in &resources { - if r == "*" { - return None; - } - if let Some(model) = r.strip_prefix("model:") { - models.insert(model.to_string()); - } - } - } - if !found_any { - return None; - } - Some(models.into_iter().collect()) +/// The model scope a policy document grants: Allow statements whose +/// Action matches `ai_gateway:use`, Resource entries `model:`. +/// A document that never grants `ai_gateway:use` grants no models. +pub fn extract_model_scope(doc: &serde_json::Value) -> ResourceScope { + extract_resource_scope(doc, "ai_gateway:use", "model:") } -/// Extract the effective MCP tool scope from a parsed PolicyDocument. -/// Same logic as `extract_allowed_models` but for `mcp_gateway:use` -/// statements and `mcp_tool:` resource prefixes. -pub fn extract_allowed_mcp_tools(doc: &serde_json::Value) -> Option> { - let statements = doc.get("Statement").and_then(|s| s.as_array())?; - let mut tools: std::collections::BTreeSet = std::collections::BTreeSet::new(); - let mut found_any = false; +/// The MCP tool scope a policy document grants: Allow statements whose +/// Action matches `mcp_gateway:use`, Resource entries `mcp_tool:`. +/// A document that never grants `mcp_gateway:use` grants no tools. +pub fn extract_mcp_tool_scope(doc: &serde_json::Value) -> ResourceScope { + extract_resource_scope(doc, "mcp_gateway:use", "mcp_tool:") +} + +fn extract_resource_scope(doc: &serde_json::Value, action: &str, prefix: &str) -> ResourceScope { + let Some(statements) = doc.get("Statement").and_then(|s| s.as_array()) else { + return ResourceScope::NotGranted; + }; + let mut items: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut granted = false; for stmt in statements { let effect = stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or(""); @@ -400,24 +392,24 @@ pub fn extract_allowed_mcp_tools(doc: &serde_json::Value) -> Option> continue; } let actions = stmt_actions(stmt); - if !action_matches_any(&actions, "mcp_gateway:use") { + if !action_matches_any(&actions, action) { continue; } - found_any = true; - let resources = stmt_resources(stmt); - for r in &resources { + granted = true; + for r in &stmt_resources(stmt) { if r == "*" { - return None; + return ResourceScope::All; } - if let Some(tool) = r.strip_prefix("mcp_tool:") { - tools.insert(tool.to_string()); + if let Some(item) = r.strip_prefix(prefix) { + items.insert(item.to_string()); } } } - if !found_any { - return None; + if granted { + ResourceScope::Only(items.into_iter().collect()) + } else { + ResourceScope::NotGranted } - Some(tools.into_iter().collect()) } /// Extract the flat set of permission strings from a parsed @@ -479,8 +471,16 @@ fn stmt_resources(stmt: &serde_json::Value) -> Vec { } } +/// True when any action pattern covers `target`: `"*"`, the exact +/// action, or a `:*` wildcard (what `extract_permissions` and +/// the console's permission check also accept). fn action_matches_any(actions: &[String], target: &str) -> bool { - actions.iter().any(|a| a == "*" || a == target) + actions.iter().any(|a| { + a == "*" + || a == target + || a.strip_suffix('*') + .is_some_and(|prefix| prefix.ends_with(':') && target.starts_with(prefix)) + }) } fn append_constraints(block: &mut SurfaceBlock, constraints: &PolicyConstraints) { @@ -870,12 +870,24 @@ pub async fn upsert_rule(pool: &PgPool, req: UpsertRule) -> Result Result { - let n = sqlx::query("DELETE FROM rate_limit_rules WHERE id = $1") - .bind(id) - .execute(pool) - .await? - .rows_affected(); +/// Delete one rule, but only if it belongs to the given subject. The +/// caller has authorized the subject, not the row id, so a row id +/// that belongs to someone else must not match. +pub async fn delete_rule( + pool: &PgPool, + id: Uuid, + subject_kind: RateLimitSubject, + subject_id: Uuid, +) -> Result { + let n = sqlx::query( + "DELETE FROM rate_limit_rules WHERE id = $1 AND subject_kind = $2 AND subject_id = $3", + ) + .bind(id) + .bind(subject_kind.as_str()) + .bind(subject_id) + .execute(pool) + .await? + .rows_affected(); Ok(n > 0) } @@ -970,12 +982,23 @@ pub async fn upsert_cap(pool: &PgPool, req: UpsertCap) -> Result Result { - let n = sqlx::query("DELETE FROM budget_caps WHERE id = $1") - .bind(id) - .execute(pool) - .await? - .rows_affected(); +/// Delete one cap, but only if it belongs to the given subject — see +/// [`delete_rule`]. +pub async fn delete_cap( + pool: &PgPool, + id: Uuid, + subject_kind: BudgetSubject, + subject_id: Uuid, +) -> Result { + let n = sqlx::query( + "DELETE FROM budget_caps WHERE id = $1 AND subject_kind = $2 AND subject_id = $3", + ) + .bind(id) + .bind(subject_kind.as_str()) + .bind(subject_id) + .execute(pool) + .await? + .rows_affected(); Ok(n > 0) } @@ -1606,16 +1629,16 @@ mod tests { } #[test] - fn extract_allowed_models_unrestricted() { + fn model_scope_unrestricted() { let doc = serde_json::json!({ "Version": "2024-01-01", "Statement": [{"Effect":"Allow","Action":"ai_gateway:use","Resource":"*"}] }); - assert_eq!(extract_allowed_models(&doc), None); + assert_eq!(extract_model_scope(&doc), ResourceScope::All); } #[test] - fn extract_allowed_models_scoped() { + fn model_scope_scoped() { let doc = serde_json::json!({ "Version": "2024-01-01", "Statement": [{ @@ -1624,8 +1647,57 @@ mod tests { "Resource":["model:gpt-4o","model:claude-sonnet-4-20250514"] }] }); - let models = extract_allowed_models(&doc).unwrap(); - assert_eq!(models, vec!["claude-sonnet-4-20250514", "gpt-4o"]); + assert_eq!( + extract_model_scope(&doc), + ResourceScope::Only(vec!["claude-sonnet-4-20250514".into(), "gpt-4o".into()]) + ); + } + + #[test] + fn a_document_without_gateway_use_grants_nothing() { + // The built-in viewer: Resource "*", but no gateway action. + let doc = serde_json::json!({ + "Version": "2024-01-01", + "Statement": [{"Effect":"Allow","Action":["providers:read","analytics:read_own"],"Resource":"*"}] + }); + assert_eq!(extract_model_scope(&doc), ResourceScope::NotGranted); + assert_eq!(extract_mcp_tool_scope(&doc), ResourceScope::NotGranted); + // A Deny is not a grant either. + let deny = serde_json::json!({ + "Version": "2024-01-01", + "Statement": [{"Effect":"Deny","Action":"ai_gateway:use","Resource":"*"}] + }); + assert_eq!(extract_model_scope(&deny), ResourceScope::NotGranted); + } + + #[test] + fn gateway_use_granted_on_other_resources_only_is_an_empty_scope() { + let doc = serde_json::json!({ + "Version": "2024-01-01", + "Statement": [{"Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use"],"Resource":["mcp_tool:srv__read"]}] + }); + assert_eq!(extract_model_scope(&doc), ResourceScope::Only(vec![])); + assert_eq!( + extract_mcp_tool_scope(&doc), + ResourceScope::Only(vec!["srv__read".into()]) + ); + } + + #[test] + fn action_wildcards_grant_gateway_use() { + for action in ["*", "ai_gateway:*"] { + let doc = serde_json::json!({ + "Version": "2024-01-01", + "Statement": [{"Effect":"Allow","Action":action,"Resource":"*"}] + }); + assert_eq!(extract_model_scope(&doc), ResourceScope::All, "{action}"); + } + let other = serde_json::json!({ + "Version": "2024-01-01", + "Statement": [{"Effect":"Allow","Action":"mcp_gateway:*","Resource":"*"}] + }); + assert_eq!(extract_model_scope(&other), ResourceScope::NotGranted); + assert_eq!(extract_mcp_tool_scope(&other), ResourceScope::All); } #[test] diff --git a/crates/gateway/src/lifecycle/mod.rs b/crates/gateway/src/lifecycle/mod.rs index c07ae3ab..f13ada5d 100644 --- a/crates/gateway/src/lifecycle/mod.rs +++ b/crates/gateway/src/lifecycle/mod.rs @@ -501,17 +501,16 @@ impl Surface for ChatCompletionSurface { )) } + /// `None` = unrestricted; otherwise the model must match an entry + /// (exactly or by prefix). An empty list allows nothing — it is what + /// a key narrowed to models its owner's roles do not grant ends up + /// with, and the MCP surface reads `[]` the same way. fn is_access_allowed(identity: &Self::Identity, candidate: &str) -> bool { - identity - .allowed_models - .as_ref() - .map(|allowed| { - allowed.is_empty() - || allowed - .iter() - .any(|m| candidate == *m || candidate.starts_with(m)) - }) - .unwrap_or(true) + identity.allowed_models.as_ref().is_none_or(|allowed| { + allowed + .iter() + .any(|m| candidate == *m || candidate.starts_with(m)) + }) } fn access_denied_response(candidate: &str) -> Self::Response { diff --git a/crates/server/src/handlers/api_keys.rs b/crates/server/src/handlers/api_keys.rs index 128f5685..bfe70a7f 100644 --- a/crates/server/src/handlers/api_keys.rs +++ b/crates/server/src/handlers/api_keys.rs @@ -1057,10 +1057,11 @@ pub struct PolicyScopeResponse { /// `None` means unrestricted at the role layer — the API-key picker /// can offer every model in the catalog. `Some(list)` is a /// patterns/exact-id allow-list that narrows the picker to those - /// entries. + /// entries; it is empty when no role of the caller grants + /// `ai_gateway:use`. pub allowed_models: Option>, - /// Same semantics as `allowed_models`. Patterns may include - /// `__*` wildcards. + /// Same semantics as `allowed_models` (empty without + /// `mcp_gateway:use`). Patterns may include `__*` wildcards. pub allowed_mcp_tools: Option>, } diff --git a/crates/server/src/handlers/limits.rs b/crates/server/src/handlers/limits.rs index c0d5ad92..1d49b64f 100644 --- a/crates/server/src/handlers/limits.rs +++ b/crates/server/src/handlers/limits.rs @@ -23,10 +23,11 @@ // containing the target subject) that covers `(kind, id)` from // the URL path. So a team_manager scoped to team:engineering // can edit limits on api_keys belonging to engineering members -// but gets 403 trying to touch marketing's keys. -// - Provider / mcp_server subjects always require global scope -// because they're platform-wide resources — see -// `AuthUser::assert_scope_for_subject`'s polymorphic dispatch. +// but gets 403 trying to touch marketing's keys. A team-scoped +// grant never covers the caller's own user or keys — see +// `AuthUser::assert_scope_for_subject`. +// - Deletes by row id are bound to the subject in the path, so an +// authorized subject can't be paired with someone else's row id. // ============================================================================ use axum::Json; @@ -439,11 +440,12 @@ pub async fn delete_rule( auth_user .assert_scope_for_subject(&state.db, "rate_limits:write", &kind, subject_id) .await?; - // Validate the kind even though we don't actually need it for - // the delete — keeps the URL shape consistent with the rest of - // the surface. - parse_rate_subject(&kind)?; - let removed = limits::delete_rule(&state.db, rule_id).await?; + // The scope check above authorized the subject in the URL, so the + // delete is bound to that subject: a rule id that belongs to + // someone else is "not found" rather than deleted. + let subject_kind = parse_rate_subject(&kind)?; + let storage_id = resolve_subject_id(&state.db, &kind, subject_id).await?; + let removed = limits::delete_rule(&state.db, rule_id, subject_kind, storage_id).await?; if !removed { return Err(AppError::NotFound("Rate limit rule not found".into())); } @@ -593,8 +595,10 @@ pub async fn delete_cap( auth_user .assert_scope_for_subject(&state.db, "rate_limits:write", &kind, subject_id) .await?; - parse_budget_subject(&kind)?; - let removed = limits::delete_cap(&state.db, cap_id).await?; + // Bound to the authorized subject — see `delete_rule`. + let subject_kind = parse_budget_subject(&kind)?; + let storage_id = resolve_subject_id(&state.db, &kind, subject_id).await?; + let removed = limits::delete_cap(&state.db, cap_id, subject_kind, storage_id).await?; if !removed { return Err(AppError::NotFound("Budget cap not found".into())); } diff --git a/crates/server/src/handlers/roles.rs b/crates/server/src/handlers/roles.rs index 5be21010..60b6d112 100644 --- a/crates/server/src/handlers/roles.rs +++ b/crates/server/src/handlers/roles.rs @@ -96,16 +96,14 @@ pub const PERMISSIONS: &[PermissionDef] = &[ // Teams are the unit of "scoped admin": a custom role with // `team_members:write` granted in scope `team:` lets the // holder add/remove members of that team without touching any - // other team. The CRUD perms below operate on the team - // catalog itself (rename, delete) and live at global scope - // because they're platform-wide bookkeeping. + // other team. `teams:read` at team scope shows that one team + // and its roster (the seeded team_manager holds both); at + // global scope it lists every team. Create / delete operate on + // the team catalog itself and need global scope. p("teams:read", "teams", "read"), p("teams:create", "teams", "create"), p("teams:update", "teams", "update"), d("teams:delete", "teams", "delete"), - // Membership management is the only perm intended to be - // granted at team scope. The handler accepts it at global - // scope too for super_admin convenience. d("team_members:write", "team_members", "write"), // --- Providers (AI upstream) --- p("providers:read", "providers", "read"), @@ -130,8 +128,6 @@ pub const PERMISSIONS: &[PermissionDef] = &[ p("users:create", "users", "create"), p("users:update", "users", "update"), d("users:delete", "users", "delete"), - p("team:read", "team", "read"), - p("team:write", "team", "write"), // --- Sessions (revoke other users) --- d("sessions:revoke", "sessions", "revoke"), // --- Roles & permissions (self-modifying — always dangerous) --- @@ -149,12 +145,9 @@ pub const PERMISSIONS: &[PermissionDef] = &[ p("analytics:read_own", "analytics", "read_own"), p("analytics:read_team", "analytics", "read_team"), p("analytics:read_all", "analytics", "read_all"), - p("audit_logs:read_own", "audit_logs", "read_own"), - p("audit_logs:read_team", "audit_logs", "read_team"), - p("audit_logs:read_all", "audit_logs", "read_all"), - // --- Gateway logs (raw request bodies — sensitive) --- - p("logs:read_own", "logs", "read_own"), - p("logs:read_team", "logs", "read_team"), + // --- Logs (gateway, MCP, audit, access and app logs) --- + // Every log endpoint is platform-wide and needs this at global + // scope; there is no per-user or per-team log view. d("logs:read_all", "logs", "read_all"), // Reading the raw request/response payload (prompts, completions, // tool arguments, tool results) is a strictly stronger right than @@ -194,6 +187,53 @@ pub(super) fn is_known_permission(key: &str) -> bool { PERMISSIONS.iter().any(|p| p.key == key) } +/// Keys that earlier releases put in the catalog and in the seeded +/// system roles, but that no handler ever checked: +/// +/// - `team:read` / `team:write` predate the `teams:*` and +/// `team_members:write` permissions the team handlers enforce. +/// - `logs:read_own` / `logs:read_team` and `audit_logs:*` — every +/// log endpoint (audit logs included) is gated on `logs:read_all` +/// at global scope; no own- or team-filtered log view exists. +/// +/// They are gone from the catalog, so the role editor no longer +/// offers them. Stored policies may still name them (the seeds only +/// insert missing roles, and custom roles could grant them), so the +/// startup check tolerates them with a warning instead of refusing to +/// boot. `db/release_migrations/2026-09-30_retire_unchecked_permissions.sql` +/// strips them. +pub(super) const RETIRED_PERMISSIONS: &[&str] = &[ + "team:read", + "team:write", + "logs:read_own", + "logs:read_team", + "audit_logs:read_own", + "audit_logs:read_team", + "audit_logs:read_all", +]; + +/// Split the permissions named by stored role policies into +/// `(unknown, retired)` entries, each formatted `"role: perm"`. +fn classify_role_permissions(rows: &[(String, serde_json::Value)]) -> (Vec, Vec) { + let all_perm_keys: Vec<&str> = PERMISSIONS.iter().map(|p| p.key).collect(); + let mut unknown = Vec::new(); + let mut retired = Vec::new(); + for (role_name, doc) in rows { + for perm in think_watch_common::limits::extract_permissions(doc, &all_perm_keys) { + if is_known_permission(&perm) { + continue; + } + let entry = format!("{role_name}: {perm}"); + if RETIRED_PERMISSIONS.contains(&perm.as_str()) { + retired.push(entry); + } else { + unknown.push(entry); + } + } + } + (unknown, retired) +} + /// Default policy document for each seeded system role. /// /// This is the **single source of truth** for "what should this @@ -212,19 +252,19 @@ pub const SYSTEM_ROLE_DEFAULTS: &[(&str, &str)] = &[ ), ( "admin", - r#"{"Version":"2024-01-01","Statement":[{"Sid":"AdminAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","api_keys:delete","api_keys:admin","providers:read","providers:create","providers:update","providers:delete","providers:rotate_key","models:read","models:write","mcp_servers:read","mcp_servers:create","mcp_servers:update","mcp_servers:delete","users:read","users:create","users:update","teams:read","teams:create","teams:update","teams:delete","team_members:write","team:read","team:write","sessions:revoke","roles:read","roles:create","roles:update","roles:delete","analytics:read_all","audit_logs:read_all","logs:read_all","log_forwarders:read","log_forwarders:write","webhooks:read","webhooks:write","content_filter:read","content_filter:write","pii_redactor:read","pii_redactor:write","rate_limits:read","rate_limits:write","settings:read","settings:write"],"Resource":"*"}]}"#, + r#"{"Version":"2024-01-01","Statement":[{"Sid":"AdminAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","api_keys:delete","api_keys:admin","providers:read","providers:create","providers:update","providers:delete","providers:rotate_key","models:read","models:write","mcp_servers:read","mcp_servers:create","mcp_servers:update","mcp_servers:delete","users:read","users:create","users:update","teams:read","teams:create","teams:update","teams:delete","team_members:write","sessions:revoke","roles:read","roles:create","roles:update","roles:delete","analytics:read_all","logs:read_all","log_forwarders:read","log_forwarders:write","webhooks:read","webhooks:write","content_filter:read","content_filter:write","pii_redactor:read","pii_redactor:write","rate_limits:read","rate_limits:write","settings:read","settings:write"],"Resource":"*"}]}"#, ), ( "team_manager", - r#"{"Version":"2024-01-01","Statement":[{"Sid":"TeamManagement","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","providers:read","models:read","mcp_servers:read","users:read","users:update","team_members:write","team:read","team:write","analytics:read_team","audit_logs:read_team","logs:read_team","rate_limits:read","rate_limits:write"],"Resource":"*"}]}"#, + r#"{"Version":"2024-01-01","Statement":[{"Sid":"TeamManagement","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","providers:read","models:read","mcp_servers:read","users:read","users:update","team_members:write","teams:read","analytics:read_team","rate_limits:read","rate_limits:write"],"Resource":"*"}]}"#, ), ( "developer", - r#"{"Version":"2024-01-01","Statement":[{"Sid":"DeveloperAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","providers:read","models:read","mcp_servers:read","analytics:read_own","audit_logs:read_own","logs:read_own"],"Resource":"*"}]}"#, + r#"{"Version":"2024-01-01","Statement":[{"Sid":"DeveloperAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","providers:read","models:read","mcp_servers:read","analytics:read_own"],"Resource":"*"}]}"#, ), ( "viewer", - r#"{"Version":"2024-01-01","Statement":[{"Sid":"ViewerAccess","Effect":"Allow","Action":["api_keys:read","providers:read","models:read","mcp_servers:read","analytics:read_own","audit_logs:read_own","logs:read_own"],"Resource":"*"}]}"#, + r#"{"Version":"2024-01-01","Statement":[{"Sid":"ViewerAccess","Effect":"Allow","Action":["api_keys:read","providers:read","models:read","mcp_servers:read","analytics:read_own"],"Resource":"*"}]}"#, ), ]; @@ -246,15 +286,14 @@ fn system_role_default_policy(name: &str) -> Option { /// fail-fast. pub async fn validate_seeded_roles(pool: &sqlx::PgPool) -> anyhow::Result<()> { let rows = repo::policy_documents(pool).await?; - let all_perm_keys: Vec<&str> = PERMISSIONS.iter().map(|p| p.key).collect(); - let mut unknown: Vec = Vec::new(); - for (role_name, doc) in &rows { - let perms = think_watch_common::limits::extract_permissions(doc, &all_perm_keys); - for perm in &perms { - if !is_known_permission(perm) { - unknown.push(format!("{role_name}: {perm}")); - } - } + let (unknown, retired) = classify_role_permissions(&rows); + if !retired.is_empty() { + tracing::warn!( + "Roles still grant retired permissions that nothing checks: {}. \ + Apply db/release_migrations/2026-09-30_retire_unchecked_permissions.sql \ + to remove them.", + retired.join(", "), + ); } if !unknown.is_empty() { anyhow::bail!( @@ -903,6 +942,73 @@ mod tests { assert!(is_known_permission("settings:write")); } + #[test] + fn unchecked_log_and_team_permissions_are_not_in_catalog() { + // No handler checks these; the catalog must not offer them. + for key in [ + "team:read", + "team:write", + "logs:read_own", + "logs:read_team", + "audit_logs:read_own", + "audit_logs:read_team", + "audit_logs:read_all", + ] { + assert!(!is_known_permission(key), "{key} is still in the catalog"); + } + } + + #[test] + fn team_manager_default_grants_the_team_read_the_handlers_check() { + // The team handlers gate listing a team, its roster and its + // roles on `teams:read`; the seeded team_manager must hold it. + let policy = system_role_default_policy("team_manager").unwrap(); + let actions: Vec<&str> = policy["Statement"][0]["Action"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(actions.contains(&"teams:read"), "{actions:?}"); + assert!(actions.contains(&"team_members:write"), "{actions:?}"); + } + + #[test] + fn stored_roles_naming_retired_permissions_still_validate() { + // An install upgraded without the release migration still has + // the old seeds; that must warn, not refuse to boot. + let rows = vec![ + ( + "team_manager".to_string(), + serde_json::json!({"Statement": [{"Effect": "Allow", + "Action": ["team:read", "team:write", "logs:read_team", "teams:read"], + "Resource": "*"}]}), + ), + ( + "custom".to_string(), + serde_json::json!({"Statement": [{"Effect": "Allow", + "Action": ["audit_logs:read_all", "nope:read"], "Resource": "*"}]}), + ), + ]; + let (unknown, retired) = classify_role_permissions(&rows); + assert_eq!(unknown, vec!["custom: nope:read".to_string()]); + assert_eq!( + retired, + vec![ + "team_manager: logs:read_team".to_string(), + "team_manager: team:read".to_string(), + "team_manager: team:write".to_string(), + "custom: audit_logs:read_all".to_string(), + ] + ); + for key in RETIRED_PERMISSIONS { + assert!( + !is_known_permission(key), + "{key} is retired but in the catalog" + ); + } + } + #[test] fn is_known_permission_rejects_unknown() { assert!(!is_known_permission("")); diff --git a/crates/server/src/handlers/teams.rs b/crates/server/src/handlers/teams.rs index a71fc387..ff9cb92f 100644 --- a/crates/server/src/handlers/teams.rs +++ b/crates/server/src/handlers/teams.rs @@ -8,7 +8,8 @@ // // Auth model: // - `teams:read` — list / get a team's metadata + member list. -// Required at GLOBAL scope to list ALL teams. Members of a +// Required at GLOBAL scope to list ALL teams; scoped to a team +// it covers that one team (the seeded `team_manager`). Members of a // team can ALSO read their own team's metadata + roster as a // baseline knowledge right (no perm needed) — see // `caller_can_view_team`. @@ -95,9 +96,9 @@ async fn caller_is_team_member( Ok(exists) } -/// Allow a team read if caller has `teams:read` globally OR is a -/// member of the team in question. Members of a team always have -/// read access to their own team's metadata + roster. +/// Allow a team read if caller holds `teams:read` globally or scoped +/// to this team, OR is a member of the team in question. Members of a +/// team always have read access to their own team's metadata + roster. async fn assert_can_view_team( auth_user: &AuthUser, pool: &sqlx::PgPool, @@ -106,7 +107,9 @@ async fn assert_can_view_team( if caller_is_team_member(pool, auth_user.claims.sub, team_id).await? { return Ok(()); } - auth_user.assert_scope_global(pool, "teams:read").await + auth_user + .assert_scope_for_team(pool, "teams:read", team_id) + .await } // ---------------------------------------------------------------------------- diff --git a/crates/server/src/middleware/api_key_auth.rs b/crates/server/src/middleware/api_key_auth.rs index ff134eca..55eda7c0 100644 --- a/crates/server/src/middleware/api_key_auth.rs +++ b/crates/server/src/middleware/api_key_auth.rs @@ -18,7 +18,14 @@ use crate::app::AppState; /// - key=None, role=None → None (unrestricted) /// - key=Some, role=None → key (role doesn't tighten) /// - key=None, role=Some → role (key doesn't tighten) -/// - key=Some, role=Some → set intersection +/// - key=Some, role=Some → the entries of either list that the +/// other list covers +/// +/// Entries are patterns, not literals (a model entry is a prefix, an MCP +/// entry may be `__*`), so the intersection keeps an entry of +/// one side when some entry of the other side covers it: a key narrowed +/// to `gpt-4o-mini` under a role granting `gpt-4o` keeps `gpt-4o-mini`. +/// An empty result allows nothing. /// /// Intersection (not union) is the right merge here because the /// per-key list is a tightening of what the user as a whole can do @@ -27,18 +34,86 @@ use crate::app::AppState; fn intersect_allowlists( key_list: Option>, role_list: Option>, + covers: fn(&str, &str) -> bool, ) -> Option> { match (key_list, role_list) { (None, None) => None, (Some(k), None) => Some(k), (None, Some(r)) => Some(r), (Some(k), Some(r)) => { - let role_set: std::collections::HashSet<&String> = r.iter().collect(); - Some(k.into_iter().filter(|m| role_set.contains(m)).collect()) + let mut out = std::collections::BTreeSet::new(); + for (side, other) in [(&k, &r), (&r, &k)] { + for entry in side { + if other.iter().any(|g| covers(g, entry)) { + out.insert(entry.clone()); + } + } + } + Some(out.into_iter().collect()) } } } +/// Model entries match by prefix (`is_access_allowed` in the gateway +/// lifecycle): `general` covers every model that `specific` covers. +fn model_entry_covers(general: &str, specific: &str) -> bool { + specific.starts_with(general) +} + +/// MCP tool patterns (`*`, `__*`, `__`): +/// `general` covers every tool that `specific` matches. +fn mcp_entry_covers(general: &str, specific: &str) -> bool { + use think_watch_mcp_gateway::access_control::is_tool_allowed; + if general == "*" || general == specific { + return true; + } + if specific == "*" || specific.ends_with("__*") { + return false; + } + is_tool_allowed(Some(&[general.to_string()]), specific) +} + +/// 403 for a key whose owner holds no role granting `surface`'s +/// `*_gateway:use`. The body is in the shape the caller's SDK reads: +/// the protocol's error object on the AI gateway (every one of them +/// carries `error.message`), a JSON-RPC error on the MCP gateway. +fn gateway_use_refused(surface: &str, path: &str) -> Response { + use axum::response::IntoResponse; + use tw_dialect::ir::Dialect; + + let permission = format!("{surface}:use"); + let message = + format!("Access denied: no role held by the owner of this API key grants {permission}."); + let (content_type, body) = if surface == "mcp_gateway" { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": {"code": -32001, "message": message}, + }); + ("application/json", body.to_string().into_bytes()) + } else { + let client = if path == "/v1/messages" { + Dialect::Anthropic + } else if path.starts_with("/v1beta/") || path.starts_with("/v1/models/") { + Dialect::Gemini + } else if path == "/v1/responses" { + Dialect::Responses + } else { + Dialect::Chat + }; + ( + "application/json", + tw_dialect::convert::error_body(client, StatusCode::FORBIDDEN.as_u16(), &message), + ) + }; + ( + StatusCode::FORBIDDEN, + [(axum::http::header::CONTENT_TYPE, content_type)], + body, + ) + .into_response() +} + /// The key a client presented, wherever its SDK puts it. /// /// `Authorization: Bearer` (OpenAI's SDKs, Claude Code with @@ -236,25 +311,46 @@ pub fn require_api_key( .unwrap_or_default(); (limits, names, constraints) } else { - // Service-account API keys (no user_id) inherit only - // the per-key constraints, since there's no user to - // resolve roles against. They get an empty role list, - // which means the MCP access controller will deny - // anything that requires a role match, and an empty - // constraint set so no role-inline limits fire. + // A key without an owner (its user row was removed + // and `user_id` set NULL) has no roles to grant + // gateway use. The users JOIN above already rejects + // such keys; refuse here as well rather than treat + // "no roles" as "no restrictions". ( - rbac::UserResourceLimits { - allowed_models: None, - allowed_mcp_tools: None, - }, + rbac::UserResourceLimits::none(), Vec::new(), think_watch_common::limits::SurfaceConstraints::default(), ) }; - let merged_models = - intersect_allowlists(row.allowed_models.clone(), role_limits.allowed_models); - let merged_mcp_tools = - intersect_allowlists(row.allowed_mcp_tools.clone(), role_limits.allowed_mcp_tools); + + // Gateway use itself. A key is only as good as its owner's + // roles: without one that grants this surface's + // `*_gateway:use`, nothing behind the gateway is reachable. + let surface_granted = match surface { + "ai_gateway" => role_limits.ai_gateway, + "mcp_gateway" => role_limits.mcp_gateway, + _ => false, + }; + if !surface_granted { + tracing::warn!( + api_key_id = %row.id, + user_id = ?row.user_id, + surface, + "API key owner holds no role granting gateway use" + ); + return Ok(gateway_use_refused(surface, request.uri().path())); + } + + let merged_models = intersect_allowlists( + row.allowed_models.clone(), + role_limits.allowed_models, + model_entry_covers, + ); + let merged_mcp_tools = intersect_allowlists( + row.allowed_mcp_tools.clone(), + role_limits.allowed_mcp_tools, + mcp_entry_covers, + ); // Load email for template header resolution ({{user_email}}) let user_email: Option = if let Some(uid) = row.user_id { @@ -354,6 +450,44 @@ mod tests { m } + fn v(items: &[&str]) -> Option> { + Some(items.iter().map(|s| s.to_string()).collect()) + } + + #[test] + fn allowlist_intersection_is_pattern_aware() { + let m = model_entry_covers; + assert_eq!(intersect_allowlists(None, None, m), None); + assert_eq!(intersect_allowlists(v(&["a"]), None, m), v(&["a"])); + assert_eq!(intersect_allowlists(None, v(&["a"]), m), v(&["a"])); + // A narrower key entry under a prefix grant is kept, and so is a + // narrower role entry under a broader key entry. + assert_eq!( + intersect_allowlists(v(&["gpt-4o-mini"]), v(&["gpt-4o"]), m), + v(&["gpt-4o-mini"]) + ); + assert_eq!( + intersect_allowlists(v(&["gpt-"]), v(&["gpt-4o", "claude"]), m), + v(&["gpt-4o"]) + ); + // Disjoint lists allow nothing. + assert_eq!(intersect_allowlists(v(&["b"]), v(&["a"]), m), v(&[])); + + let t = mcp_entry_covers; + assert_eq!( + intersect_allowlists(v(&["github__list"]), v(&["github__*"]), t), + v(&["github__list"]) + ); + assert_eq!( + intersect_allowlists(v(&["*"]), v(&["github__*", "slack__send"]), t), + v(&["github__*", "slack__send"]) + ); + assert_eq!( + intersect_allowlists(v(&["github__*"]), v(&["slack__*"]), t), + v(&[]) + ); + } + #[test] fn a_key_is_read_where_each_sdk_puts_it() { assert_eq!( diff --git a/crates/server/src/middleware/auth_guard.rs b/crates/server/src/middleware/auth_guard.rs index d585a521..22fca6cc 100644 --- a/crates/server/src/middleware/auth_guard.rs +++ b/crates/server/src/middleware/auth_guard.rs @@ -371,25 +371,56 @@ impl AuthUser { } /// Polymorphic scope check for the limits engine. The limits - /// CRUD endpoints are keyed on `(subject_kind, subject_id)` - /// where `subject_kind ∈ {user, api_key, role}`. All three are - /// admin-level writes and, for now, require the perm at global - /// scope — team-scoped grants are not enough to mutate another - /// team's user or key. A future revision could relax `user` / - /// `api_key` to allow team_manager-style scoping by looking up - /// the subject's team membership, but that's not needed today. + /// endpoints are keyed on `(subject_kind, subject_id)`: + /// + /// - `user` — global scope, or `perm` scoped to a team the user + /// belongs to. + /// - `api_key` (an `api_keys.id`) / `api_key_lineage` (a + /// `lineage_id`, as stored on override rows) — same rule, + /// applied to the key's owner. A service-account key (no + /// owner) or an id that doesn't resolve needs global scope. + /// - `role` — global scope only; roles are platform-wide. + /// + /// A team-scoped grant never covers the caller's own user or own + /// keys: otherwise a team manager, who is usually a member of the + /// team they manage, could lift their own rate limits and budget + /// caps. Only a global grant reaches the caller's own subject. pub async fn assert_scope_for_subject( &self, pool: &sqlx::PgPool, perm: &str, subject_kind: &str, - _subject_id: uuid::Uuid, + subject_id: uuid::Uuid, ) -> Result<(), AppError> { - match subject_kind { - "role" | "user" | "api_key" => self.assert_scope_global(pool, perm).await, - other => Err(AppError::BadRequest(format!( - "unknown subject_kind '{other}' (expected: user, api_key, role)" - ))), + let owner: Option = match subject_kind { + "role" => return self.assert_scope_global(pool, perm).await, + "user" => Some(subject_id), + "api_key" | "api_key_lineage" => { + let column = if subject_kind == "api_key" { + "id" + } else { + "lineage_id" + }; + let owner: Option> = sqlx::query_scalar(&format!( + "SELECT user_id FROM api_keys WHERE {column} = $1 LIMIT 1" + )) + .bind(subject_id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("scope check failed: {e}")))?; + owner.flatten() + } + other => { + return Err(AppError::BadRequest(format!( + "unknown subject_kind '{other}' (expected: user, api_key, role)" + ))); + } + }; + match owner { + Some(user_id) if user_id != self.claims.sub => { + self.assert_scope_for_user(pool, perm, user_id).await + } + _ => self.assert_scope_global(pool, perm).await, } } diff --git a/crates/test-support/tests/authz_horizontal.rs b/crates/test-support/tests/authz_horizontal.rs index 7573e1fb..76a615f5 100644 --- a/crates/test-support/tests/authz_horizontal.rs +++ b/crates/test-support/tests/authz_horizontal.rs @@ -359,9 +359,10 @@ async fn team_manager_a_can_add_member_to_own_team() { #[ignore = "integration test — run via `make test-it`"] #[tokio::test] async fn team_manager_a_cannot_write_limits_for_team_b_user() { - // Single-row limits writes go through `assert_scope_for_subject` - // which (for "user" subjects) requires global scope. A - // team-scoped manager should never reach the SQL path. + // Single-row limits writes go through `assert_scope_for_subject`, + // which for "user" subjects needs global scope or a team scope + // containing the user. A user outside manager A's team must never + // reach the SQL path. let app = TestApp::spawn().await; let team_a = make_team(&app.db, "team-a").await; let manager_a = fixtures::create_user_with_role(&app.db, "team_manager", "team", Some(team_a)) diff --git a/crates/test-support/tests/gateway_authz.rs b/crates/test-support/tests/gateway_authz.rs new file mode 100644 index 00000000..db79ed05 --- /dev/null +++ b/crates/test-support/tests/gateway_authz.rs @@ -0,0 +1,384 @@ +//! Who may use the gateways at all, and which models / MCP tools a +//! role contributes. +//! +//! - The AI gateway requires a role that grants `ai_gateway:use`, the +//! MCP gateway one that grants `mcp_gateway:use`. A key whose owner +//! holds no such role is refused with 403 before anything else runs. +//! - A role that does not grant gateway use contributes no models and +//! no tools. It used to count as "unrestricted", so adding the +//! built-in `viewer` to a user restricted to one model opened every +//! model to them. +//! - The per-key allow-list still narrows what the roles grant, and an +//! empty result means nothing is allowed, not everything. + +use serde_json::Value; +use think_watch_test_support::client::TestResponse; +use think_watch_test_support::prelude::*; +use uuid::Uuid; + +const MODEL_A: &str = "authz-model-a"; +const MODEL_B: &str = "authz-model-b"; + +/// Two routed models behind one mock upstream. +async fn two_models(app: &TestApp) -> MockProvider { + let upstream = MockProvider::openai_chat_ok(MODEL_A).await; + let provider = fixtures::create_provider( + &app.db, + &unique_name("authz-prov"), + "openai", + &upstream.uri(), + None, + ) + .await + .unwrap(); + fixtures::create_model_and_route(&app.db, provider.id, MODEL_A) + .await + .unwrap(); + fixtures::create_model_and_route(&app.db, provider.id, MODEL_B) + .await + .unwrap(); + app.rebuild_gateway_router().await; + upstream +} + +async fn custom_role(db: &sqlx::PgPool, prefix: &str, statement: Value) -> Uuid { + sqlx::query_scalar( + "INSERT INTO rbac_roles (name, is_system, policy_document) VALUES ($1, FALSE, $2) RETURNING id", + ) + .bind(unique_name(prefix)) + .bind(json!({"Version": "2024-01-01", "Statement": [statement]})) + .fetch_one(db) + .await + .unwrap() +} + +async fn assign_global(db: &sqlx::PgPool, user_id: Uuid, role_id: Uuid) { + sqlx::query( + "INSERT INTO rbac_role_assignments (user_id, role_id, scope_kind, assigned_by) + VALUES ($1, $2, 'global', $1)", + ) + .bind(user_id) + .bind(role_id) + .execute(db) + .await + .unwrap(); +} + +/// A role allowed to call `MODEL_A` only. +async fn only_model_a(db: &sqlx::PgPool) -> Uuid { + custom_role( + db, + "only-model-a", + json!({"Effect": "Allow", "Action": ["ai_gateway:use"], "Resource": [format!("model:{MODEL_A}")]}), + ) + .await +} + +async fn bare_user(app: &TestApp) -> fixtures::SeededUser { + fixtures::create_user(&app.db, &unique_email(), "Authz", "AuthzPwd_12345!") + .await + .unwrap() +} + +async fn gateway_for(app: &TestApp, user_id: Uuid, allowed_models: Option<&[&str]>) -> TestClient { + let key = fixtures::create_api_key( + &app.db, + user_id, + &unique_name("authz-key"), + &["ai_gateway", "mcp_gateway"], + allowed_models, + None, + ) + .await + .unwrap(); + let gw = app.gateway_client(); + gw.set_bearer(&key.plaintext); + gw +} + +async fn chat(gw: &TestClient, model: &str) -> TestResponse { + gw.post( + "/v1/chat/completions", + json!({"model": model, "messages": [{"role": "user", "content": "hi"}]}), + ) + .await + .unwrap() +} + +fn assert_refused(resp: &TestResponse, what: &str) { + assert!( + !resp.status.is_success(), + "{what} must be refused, got {}: {}", + resp.status, + resp.text() + ); +} + +async fn mcp_initialize(gw: &TestClient) -> TestResponse { + gw.post( + "/mcp", + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "authz-test", "version": "0"} + } + }), + ) + .await + .unwrap() +} + +async fn policy_scope(app: &TestApp, user: &fixtures::SeededUser) -> Value { + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": user.user.email, "password": user.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + let resp = con.get("/api/keys/policy-scope").await.unwrap(); + resp.assert_ok(); + resp.json().unwrap() +} + +// --------------------------------------------------------------------------- +// A role without gateway use contributes nothing +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn viewer_role_does_not_widen_model_access() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + assign_global(&app.db, user.user.id, only_model_a(&app.db).await).await; + fixtures::assign_role_global(&app.db, user.user.id, "viewer") + .await + .unwrap(); + let gw = gateway_for(&app, user.user.id, None).await; + + chat(&gw, MODEL_A).await.assert_ok(); + assert_refused( + &chat(&gw, MODEL_B).await, + "model-b under model-a-only + viewer", + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn viewer_role_does_not_widen_mcp_tool_scope() { + // The effective scope the console reports is the one the gateway + // enforces (both come from `compute_user_resource_limits`). + let app = TestApp::spawn().await; + let user = bare_user(&app).await; + let tools_only = custom_role( + &app.db, + "one-tool", + json!({"Effect": "Allow", "Action": ["mcp_gateway:use"], "Resource": ["mcp_tool:srv__read"]}), + ) + .await; + assign_global(&app.db, user.user.id, tools_only).await; + fixtures::assign_role_global(&app.db, user.user.id, "viewer") + .await + .unwrap(); + + let scope = policy_scope(&app, &user).await; + assert_eq!(scope["allowed_mcp_tools"], json!(["srv__read"]), "{scope}"); + // Neither role grants `ai_gateway:use`: no models at all. + assert_eq!(scope["allowed_models"], json!([]), "{scope}"); +} + +// --------------------------------------------------------------------------- +// Gateway use is required +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn user_without_roles_is_refused_at_the_ai_gateway() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + let gw = gateway_for(&app, user.user.id, None).await; + + let resp = chat(&gw, MODEL_A).await; + resp.assert_status(403); + assert!( + resp.text().contains("ai_gateway:use"), + "the refusal names the missing permission: {}", + resp.text() + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn viewer_only_user_is_refused_at_both_gateways() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, "viewer") + .await + .unwrap(); + let gw = gateway_for(&app, user.user.id, None).await; + + chat(&gw, MODEL_A).await.assert_status(403); + let resp = mcp_initialize(&gw).await; + resp.assert_status(403); + assert!( + resp.text().contains("mcp_gateway:use"), + "the refusal names the missing permission: {}", + resp.text() + ); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_role_granting_only_one_gateway_opens_only_that_gateway() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + assign_global(&app.db, user.user.id, only_model_a(&app.db).await).await; + let gw = gateway_for(&app, user.user.id, None).await; + + chat(&gw, MODEL_A).await.assert_ok(); + mcp_initialize(&gw).await.assert_status(403); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn a_deny_on_gateway_use_wins_over_an_allow() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, "developer") + .await + .unwrap(); + let deny = custom_role( + &app.db, + "deny-ai", + json!({"Effect": "Deny", "Action": ["ai_gateway:use"], "Resource": ["*"]}), + ) + .await; + assign_global(&app.db, user.user.id, deny).await; + let gw = gateway_for(&app, user.user.id, None).await; + + chat(&gw, MODEL_A).await.assert_status(403); + // The deny names the AI gateway only. + mcp_initialize(&gw).await.assert_ok(); +} + +// --------------------------------------------------------------------------- +// Roles that do grant gateway use keep working +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn built_in_gateway_roles_keep_full_access() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + for role in ["developer", "admin", "super_admin"] { + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, role) + .await + .unwrap(); + let gw = gateway_for(&app, user.user.id, None).await; + for model in [MODEL_A, MODEL_B] { + let resp = chat(&gw, model).await; + assert!( + resp.status.is_success(), + "{role} calling {model}: {} {}", + resp.status, + resp.text() + ); + } + let resp = mcp_initialize(&gw).await; + assert!( + resp.status.is_success(), + "{role} on the MCP gateway: {} {}", + resp.status, + resp.text() + ); + } +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn an_action_wildcard_grants_gateway_use() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + let wildcard = custom_role( + &app.db, + "ai-star", + json!({"Effect": "Allow", "Action": ["ai_gateway:*"], "Resource": ["*"]}), + ) + .await; + assign_global(&app.db, user.user.id, wildcard).await; + let gw = gateway_for(&app, user.user.id, None).await; + + chat(&gw, MODEL_B).await.assert_ok(); +} + +// --------------------------------------------------------------------------- +// The per-key allow-list +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn key_allow_list_still_narrows_a_developer() { + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + fixtures::assign_role_global(&app.db, user.user.id, "developer") + .await + .unwrap(); + let gw = gateway_for(&app, user.user.id, Some(&[MODEL_A])).await; + + chat(&gw, MODEL_A).await.assert_ok(); + assert_refused(&chat(&gw, MODEL_B).await, "model-b outside the key list"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn key_allow_list_disjoint_from_role_grants_allows_nothing() { + // The key list is written straight to the table: the console would + // refuse it, but a key minted before the role was narrowed keeps + // its old list. The intersection is empty, and empty must mean + // nothing, not everything. + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + assign_global(&app.db, user.user.id, only_model_a(&app.db).await).await; + let gw = gateway_for(&app, user.user.id, Some(&[MODEL_B])).await; + + assert_refused( + &chat(&gw, MODEL_B).await, + "model-b (not granted by the role)", + ); + assert_refused(&chat(&gw, MODEL_A).await, "model-a (not on the key list)"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn key_allow_list_within_a_prefix_grant_is_kept() { + // Role grants are prefixes (`model:authz-model-` covers both + // models); a key narrowed to one of them keeps that one. + let app = TestApp::spawn().await; + let _upstream = two_models(&app).await; + let user = bare_user(&app).await; + let prefix = custom_role( + &app.db, + "prefix", + json!({"Effect": "Allow", "Action": ["ai_gateway:use"], "Resource": ["model:authz-model-"]}), + ) + .await; + assign_global(&app.db, user.user.id, prefix).await; + let gw = gateway_for(&app, user.user.id, Some(&[MODEL_A])).await; + + chat(&gw, MODEL_A).await.assert_ok(); + assert_refused(&chat(&gw, MODEL_B).await, "model-b outside the key list"); +} diff --git a/crates/test-support/tests/team_rbac.rs b/crates/test-support/tests/team_rbac.rs new file mode 100644 index 00000000..b09cfbaa --- /dev/null +++ b/crates/test-support/tests/team_rbac.rs @@ -0,0 +1,503 @@ +//! What the seeded `team_manager` role can do when it is granted at +//! team scope — the shape its description says it is meant for. +//! +//! - read the team it manages: the team list, the team, its roster +//! and its roles (`teams:read`); +//! - manage rate limits and budget caps for the other members of that +//! team and their API keys (`rate_limits:read` / `rate_limits:write`), +//! but not for itself, not for anyone outside the team, and not by +//! pairing an in-scope subject with someone else's row id. +//! +//! A role granted at team scope administers that team and nothing more: +//! it contributes no gateway (model or MCP tool) access. A role attached +//! to the team itself is the members' working role and does count. + +use serde_json::Value; +use think_watch_test_support::prelude::*; +use uuid::Uuid; + +async fn login(app: &TestApp, user: &fixtures::SeededUser) -> TestClient { + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": user.user.email, "password": user.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + con +} + +async fn make_team(db: &sqlx::PgPool, prefix: &str) -> Uuid { + sqlx::query_scalar("INSERT INTO teams (name, description) VALUES ($1, 'rbac') RETURNING id") + .bind(unique_name(prefix)) + .fetch_one(db) + .await + .unwrap() +} + +async fn add_to_team(db: &sqlx::PgPool, team_id: Uuid, user_id: Uuid) { + sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") + .bind(team_id) + .bind(user_id) + .execute(db) + .await + .unwrap(); +} + +async fn team_manager_of(app: &TestApp, team_id: Uuid) -> fixtures::SeededUser { + fixtures::create_user_with_role(&app.db, "team_manager", "team", Some(team_id)) + .await + .unwrap() +} + +fn rule_body(max_count: i64) -> Value { + json!({ + "surface": "ai_gateway", + "metric": "requests", + "window_secs": 60, + "max_count": max_count, + }) +} + +async fn rule_count(db: &sqlx::PgPool, subject_id: Uuid) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM rate_limit_rules WHERE subject_id = $1") + .bind(subject_id) + .fetch_one(db) + .await + .unwrap() +} + +// --------------------------------------------------------------------------- +// teams:read +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_manager_can_read_the_team_it_manages() { + let app = TestApp::spawn().await; + let team_a = make_team(&app.db, "tm-read-a").await; + let team_b = make_team(&app.db, "tm-read-b").await; + let member = fixtures::create_random_user(&app.db).await.unwrap(); + add_to_team(&app.db, team_a, member.user.id).await; + // The manager is NOT a member of team A: every read below has to + // come from the team-scoped grant, not the members' baseline right. + let manager = team_manager_of(&app, team_a).await; + let con = login(&app, &manager).await; + + let resp = con.get("/api/admin/teams").await.unwrap(); + resp.assert_ok(); + let teams: Vec = resp.json().unwrap(); + let ids: Vec<&str> = teams.iter().filter_map(|t| t["id"].as_str()).collect(); + assert_eq!(ids, vec![team_a.to_string()], "team list: {teams:?}"); + + con.get(&format!("/api/admin/teams/{team_a}")) + .await + .unwrap() + .assert_ok(); + + let resp = con + .get(&format!("/api/admin/teams/{team_a}/members")) + .await + .unwrap(); + resp.assert_ok(); + let members: Vec = resp.json().unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0]["user_id"], json!(member.user.id)); + + con.get(&format!("/api/admin/teams/{team_a}/roles")) + .await + .unwrap() + .assert_ok(); + + // Another team stays out of reach. + for path in [ + format!("/api/admin/teams/{team_b}"), + format!("/api/admin/teams/{team_b}/members"), + format!("/api/admin/teams/{team_b}/roles"), + ] { + con.get(&path).await.unwrap().assert_status(403); + } +} + +// --------------------------------------------------------------------------- +// rate_limits:read / rate_limits:write +// --------------------------------------------------------------------------- + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_manager_can_manage_limits_for_members_of_its_team() { + let app = TestApp::spawn().await; + let team = make_team(&app.db, "tm-limits").await; + let member = fixtures::create_random_user(&app.db).await.unwrap(); + add_to_team(&app.db, team, member.user.id).await; + let key = fixtures::create_api_key( + &app.db, + member.user.id, + &unique_name("tm-key"), + &["ai_gateway"], + None, + None, + ) + .await + .unwrap(); + let manager = team_manager_of(&app, team).await; + let con = login(&app, &manager).await; + + // User subject: write, read, delete. + let resp = con + .post( + &format!("/api/admin/limits/user/{}/rules", member.user.id), + rule_body(10), + ) + .await + .unwrap(); + resp.assert_ok(); + let rule: Value = resp.json().unwrap(); + let rule_id = rule["id"].as_str().unwrap().to_string(); + let resp = con + .get(&format!("/api/admin/limits/user/{}/rules", member.user.id)) + .await + .unwrap(); + resp.assert_ok(); + let listed: Value = resp.json().unwrap(); + assert_eq!(listed["items"].as_array().unwrap().len(), 1); + con.post( + &format!("/api/admin/limits/user/{}/budgets", member.user.id), + json!({"period": "daily", "limit_tokens": 1000}), + ) + .await + .unwrap() + .assert_ok(); + con.delete(&format!( + "/api/admin/limits/user/{}/rules/{rule_id}", + member.user.id + )) + .await + .unwrap() + .assert_ok(); + assert_eq!(rule_count(&app.db, member.user.id).await, 0); + + // API key subject (persisted against the key's lineage). + con.post( + &format!("/api/admin/limits/api_key/{}/rules", key.row.id), + rule_body(5), + ) + .await + .unwrap() + .assert_ok(); + assert_eq!(rule_count(&app.db, key.row.lineage_id).await, 1); + + // Bulk apply across the team. + let resp = con + .post( + "/api/admin/limits/bulk/rules", + json!({ + "targets": [{"kind": "user", "id": member.user.id}], + "surface": "ai_gateway", + "metric": "requests", + "window_secs": 3600, + "max_count": 100, + }), + ) + .await + .unwrap(); + resp.assert_ok(); + let body: Value = resp.json().unwrap(); + assert_eq!(body["success_count"], json!(1), "{body}"); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_scoped_limits_grant_does_not_cover_the_manager_itself() { + // A team manager is usually a member of the team it manages. The + // team-scoped grant must not let it lift its own limits. + let app = TestApp::spawn().await; + let team = make_team(&app.db, "tm-self").await; + let manager = team_manager_of(&app, team).await; + add_to_team(&app.db, team, manager.user.id).await; + let own_key = fixtures::create_api_key( + &app.db, + manager.user.id, + &unique_name("tm-own-key"), + &["ai_gateway"], + None, + None, + ) + .await + .unwrap(); + let con = login(&app, &manager).await; + + con.post( + &format!("/api/admin/limits/user/{}/rules", manager.user.id), + rule_body(1_000_000), + ) + .await + .unwrap() + .assert_status(403); + con.post( + &format!("/api/admin/limits/api_key/{}/rules", own_key.row.id), + rule_body(1_000_000), + ) + .await + .unwrap() + .assert_status(403); + let resp = con + .post( + "/api/admin/limits/bulk/rules", + json!({ + "targets": [{"kind": "user", "id": manager.user.id}], + "surface": "ai_gateway", + "metric": "requests", + "window_secs": 60, + "max_count": 1_000_000, + }), + ) + .await + .unwrap(); + let body: Value = resp.json().unwrap(); + assert_eq!(body["success_count"], json!(0), "{body}"); + assert_eq!(rule_count(&app.db, manager.user.id).await, 0); + assert_eq!(rule_count(&app.db, own_key.row.lineage_id).await, 0); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn limits_delete_is_bound_to_the_subject_in_the_path() { + // Scope is checked on the subject in the URL; the row id must + // belong to that subject, or a manager could pair its own member's + // id with an outsider's rule id. + let app = TestApp::spawn().await; + let team = make_team(&app.db, "tm-bound").await; + let member = fixtures::create_random_user(&app.db).await.unwrap(); + add_to_team(&app.db, team, member.user.id).await; + let outsider = fixtures::create_random_user(&app.db).await.unwrap(); + let outsider_rule = fixtures::create_rate_limit_rule( + &app.db, + "user", + outsider.user.id, + "ai_gateway", + "requests", + 60, + 100, + ) + .await + .unwrap(); + let outsider_cap = + fixtures::create_budget_cap(&app.db, "user", outsider.user.id, "daily", 1000) + .await + .unwrap(); + let manager = team_manager_of(&app, team).await; + let con = login(&app, &manager).await; + + con.delete(&format!( + "/api/admin/limits/user/{}/rules/{outsider_rule}", + member.user.id + )) + .await + .unwrap() + .assert_status(404); + con.delete(&format!( + "/api/admin/limits/user/{}/budgets/{outsider_cap}", + member.user.id + )) + .await + .unwrap() + .assert_status(404); + assert_eq!(rule_count(&app.db, outsider.user.id).await, 1); + let caps: i64 = sqlx::query_scalar("SELECT count(*) FROM budget_caps WHERE id = $1") + .bind(outsider_cap) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(caps, 1); +} + +// --------------------------------------------------------------------------- +// Gateway access and team scope +// --------------------------------------------------------------------------- + +const GW_MODEL_A: &str = "tm-model-a"; +const GW_MODEL_B: &str = "tm-model-b"; + +async fn two_routed_models(app: &TestApp) -> MockProvider { + let upstream = MockProvider::openai_chat_ok(GW_MODEL_A).await; + let provider = fixtures::create_provider( + &app.db, + &unique_name("tm-prov"), + "openai", + &upstream.uri(), + None, + ) + .await + .unwrap(); + fixtures::create_model_and_route(&app.db, provider.id, GW_MODEL_A) + .await + .unwrap(); + fixtures::create_model_and_route(&app.db, provider.id, GW_MODEL_B) + .await + .unwrap(); + app.rebuild_gateway_router().await; + upstream +} + +async fn gateway_key(app: &TestApp, user_id: Uuid) -> TestClient { + let key = fixtures::create_api_key( + &app.db, + user_id, + &unique_name("tm-gw-key"), + &["ai_gateway"], + None, + None, + ) + .await + .unwrap(); + let gw = app.gateway_client(); + gw.set_bearer(&key.plaintext); + gw +} + +async fn grant_at_team_scope(db: &sqlx::PgPool, user_id: Uuid, role: &str, team: Uuid) { + sqlx::query( + r#"INSERT INTO rbac_role_assignments (user_id, role_id, scope_kind, scope_id, assigned_by) + SELECT $1, id, 'team', $2, $1 FROM rbac_roles WHERE name = $3"#, + ) + .bind(user_id) + .bind(team) + .bind(role) + .execute(db) + .await + .unwrap(); +} + +fn call(model: &str) -> Value { + json!({"model": model, "messages": [{"role": "user", "content": "hi"}]}) +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_scoped_role_grants_no_gateway_model_access() { + // Gateway requests carry no team. A role granted at scope + // `team:` is for administering that team, so it must not widen + // the models a user can call — neither for a member of the team nor + // for anyone else. + let app = TestApp::spawn().await; + let _upstream = two_routed_models(&app).await; + + let only_a: Uuid = sqlx::query_scalar( + r#"INSERT INTO rbac_roles (name, is_system, policy_document) + VALUES ($1, FALSE, '{"Version":"2024-01-01","Statement":[{"Effect":"Allow", + "Action":["ai_gateway:use"],"Resource":["model:tm-model-a"]}]}') + RETURNING id"#, + ) + .bind(unique_name("only-model-a")) + .fetch_one(&app.db) + .await + .unwrap(); + let user = fixtures::create_user(&app.db, &unique_email(), "Scoped", "ScopedPwd_12345!") + .await + .unwrap(); + sqlx::query( + "INSERT INTO rbac_role_assignments (user_id, role_id, scope_kind, assigned_by) + VALUES ($1, $2, 'global', $1)", + ) + .bind(user.user.id) + .bind(only_a) + .execute(&app.db) + .await + .unwrap(); + let gw = gateway_key(&app, user.user.id).await; + + gw.post("/v1/chat/completions", call(GW_MODEL_A)) + .await + .unwrap() + .assert_ok(); + + // `developer` (Resource "*") at the scope of a team the user is not + // a member of, then of one it is a member of. + let other_team = make_team(&app.db, "tm-gw-other").await; + grant_at_team_scope(&app.db, user.user.id, "developer", other_team).await; + let own_team = make_team(&app.db, "tm-gw-own").await; + add_to_team(&app.db, own_team, user.user.id).await; + grant_at_team_scope(&app.db, user.user.id, "developer", own_team).await; + + let denied = gw + .post("/v1/chat/completions", call(GW_MODEL_B)) + .await + .unwrap(); + assert!( + !denied.status.is_success(), + "model-b must stay refused: team-scoped grants carry no gateway access: {}", + denied.text() + ); + gw.post("/v1/chat/completions", call(GW_MODEL_A)) + .await + .unwrap() + .assert_ok(); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn team_scoped_grant_alone_gives_no_gateway_access() { + // A team manager with no global role manages its team from the + // console, but the grant does not let it call models. + let app = TestApp::spawn().await; + let _upstream = two_routed_models(&app).await; + let team = make_team(&app.db, "tm-gw-only").await; + let manager = team_manager_of(&app, team).await; + add_to_team(&app.db, team, manager.user.id).await; + let gw = gateway_key(&app, manager.user.id).await; + + let resp = gw + .post("/v1/chat/completions", call(GW_MODEL_A)) + .await + .unwrap(); + resp.assert_status(403); + assert!(resp.text().contains("ai_gateway:use"), "{}", resp.text()); + + // The console side of the grant is untouched. + let con = login(&app, &manager).await; + con.get(&format!("/api/admin/teams/{team}")) + .await + .unwrap() + .assert_ok(); +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn role_attached_to_a_team_gives_its_members_gateway_access() { + // Roles attached to the team itself ("All members automatically + // inherit the team's roles and permissions") are how a team hands + // its members their working role. They keep counting. + let app = TestApp::spawn().await; + let _upstream = two_routed_models(&app).await; + let team = make_team(&app.db, "tm-gw-inherit").await; + sqlx::query( + "INSERT INTO team_role_assignments (team_id, role_id) + SELECT $1, id FROM rbac_roles WHERE name = 'developer'", + ) + .bind(team) + .execute(&app.db) + .await + .unwrap(); + let member = fixtures::create_user(&app.db, &unique_email(), "Member", "MemberPwd_12345!") + .await + .unwrap(); + add_to_team(&app.db, team, member.user.id).await; + let outsider = fixtures::create_user(&app.db, &unique_email(), "Out", "OutPwd_12345!") + .await + .unwrap(); + + gateway_key(&app, member.user.id) + .await + .post("/v1/chat/completions", call(GW_MODEL_B)) + .await + .unwrap() + .assert_ok(); + gateway_key(&app, outsider.user.id) + .await + .post("/v1/chat/completions", call(GW_MODEL_B)) + .await + .unwrap() + .assert_status(403); +} diff --git a/db/release_migrations/2026-09-30_retire_unchecked_permissions.sql b/db/release_migrations/2026-09-30_retire_unchecked_permissions.sql new file mode 100644 index 00000000..709ce9b7 --- /dev/null +++ b/db/release_migrations/2026-09-30_retire_unchecked_permissions.sql @@ -0,0 +1,104 @@ +-- 2026-09-30: team_manager gets teams:read; retire permissions nothing checks +-- +-- What changed: +-- * The seeded `team_manager` role granted `team:read` / `team:write`, +-- but the team handlers check `teams:read` (list a team, its roster, +-- its roles). A team manager could not open the team they manage. +-- The seed now grants `teams:read` instead. +-- * `team:read`, `team:write`, `logs:read_own`, `logs:read_team`, +-- `audit_logs:read_own`, `audit_logs:read_team` and +-- `audit_logs:read_all` were in the permission catalog and in the +-- seeded roles, but no handler ever checked them (every log endpoint, +-- audit logs included, is gated on `logs:read_all` at global scope). +-- They are gone from the catalog and from the seeds. +-- +-- Why this file: +-- `db/seeds.sql` only inserts missing roles, so an existing database +-- keeps the old system-role policies. The server still boots with them +-- (the startup check logs a warning for retired keys instead of +-- failing), but team managers stay unable to read their team until the +-- policy is updated. This file: +-- 1. adds `teams:read` to `team_manager`'s Allow statements that still +-- carry the legacy `team:read` (a role an operator already edited +-- to drop `team:read` is left alone); +-- 2. removes the retired keys from every role, system and custom. +-- Removing them changes no access — nothing checked them. +-- Clicking "Reset to defaults" on a system role in the console has the +-- same effect for that role. +-- +-- Re-running is a no-op: after step 2 no role names `team:read`, so +-- step 1 matches nothing, and step 2 matches nothing. +-- +-- The rewrite does not add rows to rbac_role_history. Each user's +-- permission set is cached in Redis for 60 seconds, so team managers +-- see `teams:read` within a minute of the commit. +-- +-- Applied to: +-- - dev: pending +-- - stage: pending +-- - prod: pending + +BEGIN; + +-- Step 1. team_manager: team:read -> teams:read. +UPDATE rbac_roles r + SET policy_document = jsonb_set( + r.policy_document, + '{Statement}', + (SELECT jsonb_agg( + CASE + WHEN stmt->>'Effect' = 'Allow' + AND jsonb_typeof(stmt->'Action') = 'array' + AND stmt->'Action' ? 'team:read' + AND NOT stmt->'Action' ? 'teams:read' + THEN jsonb_set(stmt, '{Action}', (stmt->'Action') || '["teams:read"]'::jsonb) + ELSE stmt + END + ORDER BY ord) + FROM jsonb_array_elements(r.policy_document->'Statement') + WITH ORDINALITY AS s(stmt, ord))), + updated_at = now() + WHERE r.name = 'team_manager' + AND r.is_system + AND jsonb_typeof(r.policy_document->'Statement') = 'array' + AND jsonb_path_exists(r.policy_document, '$.Statement[*].Action[*] ? (@ == "team:read")'); + +-- Step 2. Strip the retired keys from every role's Action arrays. +UPDATE rbac_roles r + SET policy_document = jsonb_set( + r.policy_document, + '{Statement}', + (SELECT jsonb_agg( + CASE + WHEN jsonb_typeof(stmt->'Action') = 'array' + THEN jsonb_set( + stmt, + '{Action}', + (SELECT COALESCE(jsonb_agg(a ORDER BY o), '[]'::jsonb) + FROM jsonb_array_elements(stmt->'Action') + WITH ORDINALITY AS x(a, o) + WHERE a #>> '{}' NOT IN ( + 'team:read', 'team:write', + 'logs:read_own', 'logs:read_team', + 'audit_logs:read_own', 'audit_logs:read_team', + 'audit_logs:read_all'))) + ELSE stmt + END + ORDER BY ord) + FROM jsonb_array_elements(r.policy_document->'Statement') + WITH ORDINALITY AS s(stmt, ord))), + updated_at = now() + WHERE jsonb_typeof(r.policy_document->'Statement') = 'array' + AND jsonb_path_exists( + r.policy_document, + '$.Statement[*].Action[*] ? (@ == "team:read" || @ == "team:write" + || @ == "logs:read_own" || @ == "logs:read_team" + || @ == "audit_logs:read_own" || @ == "audit_logs:read_team" + || @ == "audit_logs:read_all")'); + +-- Check: no role should list here. +SELECT name, stmt->'Action' AS actions + FROM rbac_roles, jsonb_array_elements(policy_document->'Statement') AS stmt + WHERE jsonb_path_exists(stmt, '$.Action[*] ? (@ like_regex "^(team:(read|write)|logs:read_(own|team)|audit_logs:)")'); + +COMMIT; diff --git a/db/seeds.sql b/db/seeds.sql index f81ea791..66dd1f92 100644 --- a/db/seeds.sql +++ b/db/seeds.sql @@ -22,22 +22,22 @@ INSERT INTO rbac_roles (name, description, is_system, policy_document) VALUES ('admin', 'Administrative access. Manages providers, MCP servers, API keys, and users.', TRUE, - '{"Version":"2024-01-01","Statement":[{"Sid":"AdminAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","api_keys:delete","api_keys:admin","providers:read","providers:create","providers:update","providers:delete","providers:rotate_key","models:read","models:write","mcp_servers:read","mcp_servers:create","mcp_servers:update","mcp_servers:delete","users:read","users:create","users:update","teams:read","teams:create","teams:update","teams:delete","team_members:write","team:read","team:write","sessions:revoke","roles:read","roles:create","roles:update","roles:delete","analytics:read_all","audit_logs:read_all","logs:read_all","log_forwarders:read","log_forwarders:write","webhooks:read","webhooks:write","content_filter:read","content_filter:write","pii_redactor:read","pii_redactor:write","rate_limits:read","rate_limits:write","settings:read","settings:write"],"Resource":"*"}]}' + '{"Version":"2024-01-01","Statement":[{"Sid":"AdminAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","api_keys:delete","api_keys:admin","providers:read","providers:create","providers:update","providers:delete","providers:rotate_key","models:read","models:write","mcp_servers:read","mcp_servers:create","mcp_servers:update","mcp_servers:delete","users:read","users:create","users:update","teams:read","teams:create","teams:update","teams:delete","team_members:write","sessions:revoke","roles:read","roles:create","roles:update","roles:delete","analytics:read_all","logs:read_all","log_forwarders:read","log_forwarders:write","webhooks:read","webhooks:write","content_filter:read","content_filter:write","pii_redactor:read","pii_redactor:write","rate_limits:read","rate_limits:write","settings:read","settings:write"],"Resource":"*"}]}' ), ('team_manager', 'Team-level management. Manages members, API keys, and rate limits for the team it''s assigned to. Intended to be granted with scope_kind = team.', TRUE, - '{"Version":"2024-01-01","Statement":[{"Sid":"TeamManagement","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","providers:read","models:read","mcp_servers:read","users:read","users:update","team_members:write","team:read","team:write","analytics:read_team","audit_logs:read_team","logs:read_team","rate_limits:read","rate_limits:write"],"Resource":"*"}]}' + '{"Version":"2024-01-01","Statement":[{"Sid":"TeamManagement","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","api_keys:rotate","providers:read","models:read","mcp_servers:read","users:read","users:update","team_members:write","teams:read","analytics:read_team","rate_limits:read","rate_limits:write"],"Resource":"*"}]}' ), ('developer', 'Standard developer. Uses the gateway, manages own API keys, sees own usage.', TRUE, - '{"Version":"2024-01-01","Statement":[{"Sid":"DeveloperAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","providers:read","models:read","mcp_servers:read","analytics:read_own","audit_logs:read_own","logs:read_own"],"Resource":"*"}]}' + '{"Version":"2024-01-01","Statement":[{"Sid":"DeveloperAccess","Effect":"Allow","Action":["ai_gateway:use","mcp_gateway:use","mcp:connect","api_keys:read","api_keys:create","api_keys:update","providers:read","models:read","mcp_servers:read","analytics:read_own"],"Resource":"*"}]}' ), ('viewer', 'Read-only access. Can browse providers and analytics but not modify anything.', TRUE, - '{"Version":"2024-01-01","Statement":[{"Sid":"ViewerAccess","Effect":"Allow","Action":["api_keys:read","providers:read","models:read","mcp_servers:read","analytics:read_own","audit_logs:read_own","logs:read_own"],"Resource":"*"}]}' + '{"Version":"2024-01-01","Statement":[{"Sid":"ViewerAccess","Effect":"Allow","Action":["api_keys:read","providers:read","models:read","mcp_servers:read","analytics:read_own"],"Resource":"*"}]}' ) ON CONFLICT (name) DO NOTHING; INSERT INTO api_key_surface_kinds (name, display_name, description) VALUES diff --git a/deploy/helm/think-watch/Chart.yaml b/deploy/helm/think-watch/Chart.yaml index f4b3caf3..0014bccb 100644 --- a/deploy/helm/think-watch/Chart.yaml +++ b/deploy/helm/think-watch/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: think-watch description: Enterprise AI API Gateway & MCP Management Platform type: application -version: 2.1.0 -appVersion: "2.1.0" +version: 2.2.0 +appVersion: "2.2.0" keywords: - ai - gateway diff --git a/web/package.json b/web/package.json index df7562f1..f011cffb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "2.1.0", + "version": "2.2.0", "type": "module", "packageManager": "pnpm@11.0.0", "scripts": { diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index 98e4085b..ce34507b 100644 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -42,8 +42,8 @@ const DYNAMIC_ENUMS = { // through to the raw key (shown uppercased) in the permission tree. 'permissions.resource.${_}': [ 'ai_gateway', 'mcp_gateway', 'api_keys', 'providers', 'mcp_servers', - 'models', 'users', 'team', 'teams', 'team_members', 'sessions', - 'roles', 'analytics', 'audit_logs', 'logs', 'log_forwarders', + 'models', 'users', 'teams', 'team_members', 'sessions', + 'roles', 'analytics', 'logs', 'log_forwarders', 'webhooks', 'content_filter', 'pii_redactor', 'rate_limits', 'settings', 'system', ], diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 2ce9d718..154f0e8a 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1094,6 +1094,7 @@ "scopeTeamPick": "Pick a team", "scopeTeamRequired": "Pick a team for the team scope.", "scopeNoTeams": "No teams to scope to.", + "scopeTeamNoGateway": "A role assigned at team scope grants administration of that team only. It does not grant access to models or MCP tools through the gateways.", "userId": "User ID", "effectivePermissions": "Effective permissions", "effectivePermissionsDesc": "Union of every selected role. This is what the user will be able to do once you save.", @@ -1214,15 +1215,13 @@ "providers": "Providers", "mcp_servers": "MCP servers", "users": "Users", - "team": "Team", "teams": "Teams", "team_members": "Team members", "models": "Models", "sessions": "Sessions", "roles": "Roles", "analytics": "Analytics", - "audit_logs": "Audit logs", - "logs": "Gateway logs", + "logs": "Logs", "log_forwarders": "Log forwarders", "webhooks": "Webhooks", "content_filter": "Content filter", diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index 0cf22ce1..21e5dae9 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -1094,6 +1094,7 @@ "scopeTeamPick": "选择团队", "scopeTeamRequired": "团队范围必须选择一个团队。", "scopeNoTeams": "没有可选的团队。", + "scopeTeamNoGateway": "团队范围的角色只授予该团队的管理权限,不授予通过网关使用模型或 MCP 工具的权限。", "userId": "用户 ID", "effectivePermissions": "有效权限", "effectivePermissionsDesc": "所有所选角色的并集。这就是保存后用户能够执行的全部操作。", @@ -1214,15 +1215,13 @@ "providers": "提供商", "mcp_servers": "MCP 服务器", "users": "用户", - "team": "所属团队", "teams": "团队", "team_members": "团队成员", "models": "模型", "sessions": "会话", "roles": "角色", "analytics": "分析", - "audit_logs": "审计日志", - "logs": "网关日志", + "logs": "日志", "log_forwarders": "日志转发器", "webhooks": "Webhook", "content_filter": "内容过滤", diff --git a/web/src/routes/admin/roles/types.ts b/web/src/routes/admin/roles/types.ts index 52e5a4c2..55f6c015 100644 --- a/web/src/routes/admin/roles/types.ts +++ b/web/src/routes/admin/roles/types.ts @@ -204,8 +204,6 @@ export const SIMPLE_TEMPLATES: SimpleTemplate[] = [ 'providers:read', 'mcp_servers:read', 'analytics:read_own', - 'audit_logs:read_own', - 'logs:read_own', ], }, // Read-only across the surface a non-admin can browse. @@ -219,8 +217,6 @@ export const SIMPLE_TEMPLATES: SimpleTemplate[] = [ 'mcp_servers:read', 'roles:read', 'analytics:read_own', - 'audit_logs:read_own', - 'logs:read_own', 'settings:read', 'log_forwarders:read', 'webhooks:read', @@ -253,7 +249,6 @@ export const SIMPLE_TEMPLATES: SimpleTemplate[] = [ 'mcp_servers:update', 'mcp_servers:delete', 'analytics:read_all', - 'audit_logs:read_all', 'logs:read_all', 'log_forwarders:read', 'log_forwarders:write', @@ -269,7 +264,7 @@ export const SIMPLE_TEMPLATES: SimpleTemplate[] = [ // Analytics-only viewer (e.g. an SRE dashboard or finance owner). { id: 'analytics_only', - permissions: ['analytics:read_all', 'audit_logs:read_all', 'logs:read_all'], + permissions: ['analytics:read_all', 'logs:read_all'], }, ]; diff --git a/web/src/routes/admin/users.tsx b/web/src/routes/admin/users.tsx index 8248c0a7..5944ba6c 100644 --- a/web/src/routes/admin/users.tsx +++ b/web/src/routes/admin/users.tsx @@ -1302,6 +1302,9 @@ function RoleAssignmentEditor({ )} )} + {(pendingKind === 'team' || value.some((a) => parseScope(a.scope).kind === 'team')) && ( +

{t('users.scopeTeamNoGateway')}

+ )} ); } @@ -1317,7 +1320,11 @@ function RoleAssignmentEditor({ // // `null` allow_lists win — if any role grants unrestricted access, // the union is unrestricted, matching the backend rule that "least -// privilege is expressed by NOT assigning the role". +// privilege is expressed by NOT assigning the role". Models and tools +// come only from roles that grant `ai_gateway:use` / `mcp_gateway:use` +// and are assigned at global scope, as in rbac::compute_user_resource_limits: +// a team-scoped assignment administers that team and grants no gateway +// access. // ---------------------------------------------------------------------------- function EffectivePermissionsPreview({ @@ -1348,10 +1355,15 @@ function EffectivePermissionsPreview({ // an empty array made every preview report zero permissions. const parsed = policyToPerms(JSON.stringify(role.policy_document), availablePermissions); for (const p of parsed.perms) perms.add(p); - if (parsed.models === null) modelsUnrestricted = true; - else for (const m of parsed.models) models.add(m); - if (parsed.mcpTools === null) toolsUnrestricted = true; - else for (const t of parsed.mcpTools) tools.add(t); + if (parseScope(a.scope).kind !== 'global') continue; + if (parsed.perms.has('ai_gateway:use')) { + if (parsed.models === null) modelsUnrestricted = true; + else for (const m of parsed.models) models.add(m); + } + if (parsed.perms.has('mcp_gateway:use')) { + if (parsed.mcpTools === null) toolsUnrestricted = true; + else for (const t of parsed.mcpTools) tools.add(t); + } } // Group permissions by their resource prefix for a compact list.