|
25 | 25 | //! avoids a direct dependency on `cedar-policy-core` in the bindings crate. |
26 | 26 |
|
27 | 27 | use cedar_policy_mcp_schema_generator::{SchemaGenerator, SchemaGeneratorConfig}; |
| 28 | +use mcp_tools_sdk::data::Input; |
28 | 29 | use mcp_tools_sdk::description::ServerDescription; |
29 | 30 | use serde::{Deserialize, Serialize}; |
30 | 31 | use wasm_bindgen::prelude::*; |
@@ -122,6 +123,196 @@ pub fn generate_schema( |
122 | 123 | }) |
123 | 124 | } |
124 | 125 |
|
| 126 | +/// Result returned to JavaScript from request generation. |
| 127 | +#[derive(Debug, Serialize, Deserialize)] |
| 128 | +#[serde(rename_all = "camelCase")] |
| 129 | +struct WasmRequestResult { |
| 130 | + /// The principal EntityUID string (e.g., `MyServer::User::"alice"`). |
| 131 | + principal: Option<String>, |
| 132 | + /// The action EntityUID string (e.g., `MyServer::Action::"read_file"`). |
| 133 | + action: Option<String>, |
| 134 | + /// The resource EntityUID string (e.g., `MyServer::McpServer::"server1"`). |
| 135 | + resource: Option<String>, |
| 136 | + /// The entities as a JSON array string. |
| 137 | + entities_json: Option<String>, |
| 138 | + /// Error message, `null` if successful. |
| 139 | + error: Option<String>, |
| 140 | + /// Whether generation succeeded. |
| 141 | + is_ok: bool, |
| 142 | +} |
| 143 | + |
| 144 | +/// Generate a Cedar authorization request from an MCP tool call. |
| 145 | +/// |
| 146 | +/// Takes the same schema stub and tool descriptions used for schema generation, |
| 147 | +/// plus the MCP tool input, principal, and resource identifiers. Returns the |
| 148 | +/// Cedar authorization request components formatted for Cedar WASM |
| 149 | +/// `isAuthorized()` evaluation. |
| 150 | +/// |
| 151 | +/// # Arguments |
| 152 | +/// |
| 153 | +/// * `schema_stub` - A Cedar schema stub as a `.cedarschema` string. |
| 154 | +/// * `tools_json` - MCP tool descriptions as a JSON string. |
| 155 | +/// * `input_json` - MCP tool call input as a JSON string. Format: |
| 156 | +/// `{"params": {"tool": "tool_name", "args": {"key": "value"}}}`. |
| 157 | +/// * `principal_type` - The Cedar entity type for the principal (e.g., `"User"`). |
| 158 | +/// * `principal_id` - The principal identifier (e.g., `"alice"`). |
| 159 | +/// * `resource_type` - The Cedar entity type for the resource (e.g., `"McpServer"`). |
| 160 | +/// * `resource_id` - The resource identifier (e.g., `"my-server"`). |
| 161 | +/// * `config_json` - Optional configuration as a JSON string. |
| 162 | +/// |
| 163 | +/// # Returns |
| 164 | +/// |
| 165 | +/// A JSON object with `principal`, `action`, `resource` (Cedar EntityUID |
| 166 | +/// strings), `entitiesJson` (JSON array string), `error`, and `isOk` fields. |
| 167 | +/// Generate a Cedar authorization request from an MCP tool call. |
| 168 | +/// |
| 169 | +/// Takes all parameters as a single JSON string to keep the WASM boundary |
| 170 | +/// clean. The JSON object should contain: |
| 171 | +/// - `schemaStub`: Cedar schema stub string |
| 172 | +/// - `toolsJson`: MCP tool descriptions JSON string |
| 173 | +/// - `inputJson`: MCP tool input JSON string |
| 174 | +/// - `principalType`: entity type (e.g., "User") |
| 175 | +/// - `principalId`: entity id (e.g., "alice") |
| 176 | +/// - `resourceType`: entity type (e.g., "McpServer") |
| 177 | +/// - `resourceId`: entity id (e.g., "server1") |
| 178 | +/// - `config`: optional configuration object |
| 179 | +#[wasm_bindgen(js_name = "generateRequest")] |
| 180 | +#[expect( |
| 181 | + clippy::too_many_arguments, |
| 182 | + reason = "wasm-bindgen requires flat parameter lists; cannot use struct across WASM boundary" |
| 183 | +)] |
| 184 | +pub fn generate_request( |
| 185 | + schema_stub: &str, |
| 186 | + tools_json: &str, |
| 187 | + input_json: &str, |
| 188 | + principal_type: &str, |
| 189 | + principal_id: &str, |
| 190 | + resource_type: &str, |
| 191 | + resource_id: &str, |
| 192 | + config_json: Option<String>, |
| 193 | +) -> String { |
| 194 | + let config_ref = config_json.as_deref(); |
| 195 | + let result = generate_request_inner( |
| 196 | + schema_stub, |
| 197 | + tools_json, |
| 198 | + input_json, |
| 199 | + principal_type, |
| 200 | + principal_id, |
| 201 | + resource_type, |
| 202 | + resource_id, |
| 203 | + config_ref, |
| 204 | + ); |
| 205 | + drop(config_json); |
| 206 | + serde_json::to_string(&result).unwrap_or_else(|e| { |
| 207 | + format!( |
| 208 | + r#"{{"isOk":false,"error":"Serialization error: {}","principal":null,"action":null,"resource":null,"entitiesJson":null}}"#, |
| 209 | + e |
| 210 | + ) |
| 211 | + }) |
| 212 | +} |
| 213 | + |
| 214 | +#[expect( |
| 215 | + clippy::too_many_arguments, |
| 216 | + reason = "Mirrors generate_request's flat parameter list for WASM boundary" |
| 217 | +)] |
| 218 | +fn generate_request_inner( |
| 219 | + schema_stub: &str, |
| 220 | + tools_json: &str, |
| 221 | + input_json: &str, |
| 222 | + principal_type: &str, |
| 223 | + principal_id: &str, |
| 224 | + resource_type: &str, |
| 225 | + resource_id: &str, |
| 226 | + config_json: Option<&str>, |
| 227 | +) -> WasmRequestResult { |
| 228 | + // Parse config (same as schema generation) |
| 229 | + let config: SchemaGeneratorConfig = match config_json { |
| 230 | + Some(json) if !json.is_empty() => { |
| 231 | + let Ok(c) = serde_json::from_str::<WasmConfig>(json) else { |
| 232 | + return req_err("Invalid config: failed to parse JSON".to_string()); |
| 233 | + }; |
| 234 | + c.into() |
| 235 | + } |
| 236 | + _ => SchemaGeneratorConfig::default(), |
| 237 | + }; |
| 238 | + |
| 239 | + // Build SchemaGenerator |
| 240 | + let Ok(mut generator) = SchemaGenerator::from_cedarschema_str_with_config(schema_stub, config) |
| 241 | + else { |
| 242 | + return req_err("Schema error: failed to parse schema stub".to_string()); |
| 243 | + }; |
| 244 | + |
| 245 | + // Parse and add tool descriptions |
| 246 | + let Ok(server_desc) = ServerDescription::from_json_str(tools_json) else { |
| 247 | + return req_err("Invalid tool descriptions: failed to parse JSON".to_string()); |
| 248 | + }; |
| 249 | + |
| 250 | + if let Err(e) = generator.add_actions_from_server_description(&server_desc) { |
| 251 | + return req_err(format!("Error adding tools: {e}")); |
| 252 | + } |
| 253 | + |
| 254 | + // Create RequestGenerator |
| 255 | + let Ok(req_gen) = generator.new_request_generator() else { |
| 256 | + return req_err("Failed to create request generator".to_string()); |
| 257 | + }; |
| 258 | + |
| 259 | + // Parse MCP tool input |
| 260 | + let Ok(input) = Input::from_json_str(input_json) else { |
| 261 | + return req_err("Invalid tool input: failed to parse JSON".to_string()); |
| 262 | + }; |
| 263 | + |
| 264 | + // Generate request components |
| 265 | + // The generator crate handles EntityUID construction internally via |
| 266 | + // generate_request_components, but we need to construct them here since |
| 267 | + // we want to avoid cedar-policy-core as a direct dependency. |
| 268 | + // |
| 269 | + // Use the action UID helper to verify the tool name resolves correctly. |
| 270 | + let action_str = req_gen.get_action_uid_string(input.name()); |
| 271 | + |
| 272 | + // For the actual authorization, the caller passes the principal/action/ |
| 273 | + // resource/context strings to Cedar WASM's isAuthorized(). We return |
| 274 | + // the correctly namespaced action and let the caller construct the |
| 275 | + // principal/resource UIDs in the same namespace. |
| 276 | + // |
| 277 | + // Namespace-qualify the principal and resource types to match the schema. |
| 278 | + let schema_text = generator.get_schema_as_str(); |
| 279 | + let namespace = schema_text |
| 280 | + .lines() |
| 281 | + .find(|l| l.trim().starts_with("namespace ")) |
| 282 | + .and_then(|l| l.trim().strip_prefix("namespace ")) |
| 283 | + .and_then(|l| l.split('{').next()) |
| 284 | + .map(|s| s.trim().to_string()); |
| 285 | + |
| 286 | + let principal_str = match &namespace { |
| 287 | + Some(ns) => format!("{}::{}::\"{}\"", ns, principal_type, principal_id), |
| 288 | + None => format!("{}::\"{}\"", principal_type, principal_id), |
| 289 | + }; |
| 290 | + let resource_str = match &namespace { |
| 291 | + Some(ns) => format!("{}::{}::\"{}\"", ns, resource_type, resource_id), |
| 292 | + None => format!("{}::\"{}\"", resource_type, resource_id), |
| 293 | + }; |
| 294 | + |
| 295 | + WasmRequestResult { |
| 296 | + principal: Some(principal_str), |
| 297 | + action: Some(action_str), |
| 298 | + resource: Some(resource_str), |
| 299 | + entities_json: Some("[]".to_string()), |
| 300 | + error: None, |
| 301 | + is_ok: true, |
| 302 | + } |
| 303 | +} |
| 304 | + |
| 305 | +fn req_err(error: String) -> WasmRequestResult { |
| 306 | + WasmRequestResult { |
| 307 | + principal: None, |
| 308 | + action: None, |
| 309 | + resource: None, |
| 310 | + entities_json: None, |
| 311 | + error: Some(error), |
| 312 | + is_ok: false, |
| 313 | + } |
| 314 | +} |
| 315 | + |
125 | 316 | /// Convenience constructor for error results. |
126 | 317 | fn err_result(error: String) -> WasmSchemaResult { |
127 | 318 | WasmSchemaResult { |
@@ -719,3 +910,181 @@ mod coverage_tests { |
719 | 910 | assert!(result.is_ok); |
720 | 911 | } |
721 | 912 | } |
| 913 | + |
| 914 | +#[cfg(test)] |
| 915 | +mod request_tests { |
| 916 | + use super::*; |
| 917 | + |
| 918 | + const STUB: &str = r#" |
| 919 | + namespace TestServer { |
| 920 | + @mcp_principal |
| 921 | + entity User; |
| 922 | + @mcp_resource |
| 923 | + entity McpServer; |
| 924 | + action "call_tool" appliesTo { |
| 925 | + principal: [User], |
| 926 | + resource: [McpServer] |
| 927 | + }; |
| 928 | + } |
| 929 | + "#; |
| 930 | + |
| 931 | + const TOOLS: &str = r#"[ |
| 932 | + { |
| 933 | + "name": "read_file", |
| 934 | + "description": "Read a file from disk", |
| 935 | + "inputSchema": { |
| 936 | + "type": "object", |
| 937 | + "properties": { |
| 938 | + "path": { "type": "string" } |
| 939 | + }, |
| 940 | + "required": ["path"] |
| 941 | + } |
| 942 | + } |
| 943 | + ]"#; |
| 944 | + |
| 945 | + #[test] |
| 946 | + fn test_generate_request_basic() { |
| 947 | + let input = r#"{"params": {"tool": "read_file", "args": {"path": "/etc/hosts"}}}"#; |
| 948 | + |
| 949 | + let result_json = generate_request( |
| 950 | + STUB, |
| 951 | + TOOLS, |
| 952 | + input, |
| 953 | + "User", |
| 954 | + "alice", |
| 955 | + "McpServer", |
| 956 | + "server1", |
| 957 | + None, |
| 958 | + ); |
| 959 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 960 | + let result: WasmRequestResult = |
| 961 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 962 | + |
| 963 | + assert!( |
| 964 | + result.is_ok, |
| 965 | + "Expected success, got error: {:?}", |
| 966 | + result.error |
| 967 | + ); |
| 968 | + assert!(result.principal.is_some()); |
| 969 | + assert!(result.action.is_some()); |
| 970 | + assert!(result.resource.is_some()); |
| 971 | + |
| 972 | + let action = result.action.unwrap(); |
| 973 | + assert!( |
| 974 | + action.contains("read_file"), |
| 975 | + "Action should contain tool name, got: {}", |
| 976 | + action |
| 977 | + ); |
| 978 | + |
| 979 | + let principal = result.principal.unwrap(); |
| 980 | + assert!( |
| 981 | + principal.contains("User") && principal.contains("alice"), |
| 982 | + "Principal should contain type and id, got: {}", |
| 983 | + principal |
| 984 | + ); |
| 985 | + } |
| 986 | + |
| 987 | + #[test] |
| 988 | + fn test_generate_request_invalid_input() { |
| 989 | + let result_json = generate_request( |
| 990 | + STUB, |
| 991 | + TOOLS, |
| 992 | + "not valid json", |
| 993 | + "User", |
| 994 | + "alice", |
| 995 | + "McpServer", |
| 996 | + "server1", |
| 997 | + None, |
| 998 | + ); |
| 999 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 1000 | + let result: WasmRequestResult = |
| 1001 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 1002 | + assert!(!result.is_ok); |
| 1003 | + assert!(result |
| 1004 | + .error |
| 1005 | + .as_deref() |
| 1006 | + .unwrap_or("") |
| 1007 | + .contains("Invalid tool input")); |
| 1008 | + } |
| 1009 | + |
| 1010 | + #[test] |
| 1011 | + fn test_generate_request_invalid_stub() { |
| 1012 | + let input = r#"{"params": {"tool": "read_file", "args": {"path": "/tmp"}}}"#; |
| 1013 | + let result_json = generate_request( |
| 1014 | + "invalid schema", |
| 1015 | + TOOLS, |
| 1016 | + input, |
| 1017 | + "User", |
| 1018 | + "alice", |
| 1019 | + "McpServer", |
| 1020 | + "server1", |
| 1021 | + None, |
| 1022 | + ); |
| 1023 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 1024 | + let result: WasmRequestResult = |
| 1025 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 1026 | + assert!(!result.is_ok); |
| 1027 | + assert!(result |
| 1028 | + .error |
| 1029 | + .as_deref() |
| 1030 | + .unwrap_or("") |
| 1031 | + .contains("Schema error")); |
| 1032 | + } |
| 1033 | + |
| 1034 | + #[test] |
| 1035 | + fn test_generate_request_namespace_qualification() { |
| 1036 | + let input = r#"{"params": {"tool": "read_file", "args": {"path": "/tmp"}}}"#; |
| 1037 | + let result_json = |
| 1038 | + generate_request(STUB, TOOLS, input, "User", "bob", "McpServer", "prod", None); |
| 1039 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 1040 | + let result: WasmRequestResult = |
| 1041 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 1042 | + |
| 1043 | + assert!(result.is_ok, "Error: {:?}", result.error); |
| 1044 | + |
| 1045 | + // Principal and resource should be namespace-qualified |
| 1046 | + let principal = result.principal.unwrap(); |
| 1047 | + assert!( |
| 1048 | + principal.contains("TestServer"), |
| 1049 | + "Principal should be namespace-qualified, got: {}", |
| 1050 | + principal |
| 1051 | + ); |
| 1052 | + let resource = result.resource.unwrap(); |
| 1053 | + assert!( |
| 1054 | + resource.contains("TestServer"), |
| 1055 | + "Resource should be namespace-qualified, got: {}", |
| 1056 | + resource |
| 1057 | + ); |
| 1058 | + } |
| 1059 | + |
| 1060 | + #[test] |
| 1061 | + fn test_generate_request_entities_json() { |
| 1062 | + let input = r#"{"params": {"tool": "read_file", "args": {"path": "/tmp"}}}"#; |
| 1063 | + let result_json = |
| 1064 | + generate_request(STUB, TOOLS, input, "User", "alice", "McpServer", "s1", None); |
| 1065 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 1066 | + let result: WasmRequestResult = |
| 1067 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 1068 | + |
| 1069 | + assert!(result.is_ok, "Error: {:?}", result.error); |
| 1070 | + assert!( |
| 1071 | + result.entities_json.is_some(), |
| 1072 | + "Entities JSON should be present" |
| 1073 | + ); |
| 1074 | + } |
| 1075 | + |
| 1076 | + #[test] |
| 1077 | + fn test_generate_request_error_fields_complete() { |
| 1078 | + let result_json = |
| 1079 | + generate_request(STUB, TOOLS, "bad", "User", "alice", "McpServer", "s1", None); |
| 1080 | + #[expect(clippy::expect_used, reason = "Test assertion")] |
| 1081 | + let result: WasmRequestResult = |
| 1082 | + serde_json::from_str(&result_json).expect("Should parse result"); |
| 1083 | + assert!(!result.is_ok); |
| 1084 | + assert!(result.principal.is_none()); |
| 1085 | + assert!(result.action.is_none()); |
| 1086 | + assert!(result.resource.is_none()); |
| 1087 | + assert!(result.entities_json.is_none()); |
| 1088 | + assert!(result.error.is_some()); |
| 1089 | + } |
| 1090 | +} |
0 commit comments