Skip to content

Commit 070e08d

Browse files
committed
feat: RequestGenerator WASM bindings for authorization requests
Addresses cedar-policy#72. Per Victor's guidance: single crate, sync-only, camelCase. Extends `cedar-policy-mcp-schema-generator-wasm` (the crate from cedar-policy#64) with request-generation bindings that let JS/TS agent hosts construct Cedar authorization requests from MCP tool-call inputs. ## What this adds ### Generator crate (`cedar-policy-mcp-schema-generator`) - `AuthorizationComponents` struct — serializable principal/action/ resource/entities from a Cedar authorization request. - `RequestGenerator::get_action_uid_string(tool_name)` — returns the fully-qualified Cedar EntityUID string for a tool action. - `RequestGenerator::generate_request_components(input, principal, resource, context, entities, output)` — returns `AuthorizationComponents` ready for JSON serialization. ### WASM crate (`cedar-policy-mcp-schema-generator-wasm`) - `generateRequest(schemaStub, toolsJson, inputJson, principalType, principalId, resourceType, resourceId, configJson?)` — sync JS function that returns `{ principal, action, resource, entitiesJson, error, isOk }` in camelCase matching cedar-wasm conventions. - 6 unit tests (basic, invalid-input, invalid-stub, namespace qualification, entities JSON, error-field completeness). ## Why this matters Together with cedar-policy#64 (schema generation), this gives the complete build-time + runtime WASM toolchain for Cedar-based agent authorization: 1. **Schema generation** (cedar-policy#64): MCP tool descriptions → Cedar schema 2. **Request generation** (this PR): MCP tool call → Cedar request 3. **Authorization**: pass request + schema to `cedar-wasm` `isAuthorized()` for the decision All three steps now run in any JS/TS environment via WASM without a Rust toolchain. ## Verification - 237 tests passing (all workspace tests) - cargo clippy clean (workspace lints, `too_many_arguments` expected) - cargo fmt clean Signed-off-by: tommylauren <tfarley@utexas.edu>
1 parent af35ea5 commit 070e08d

4 files changed

Lines changed: 458 additions & 3 deletions

File tree

rust/cedar-policy-mcp-schema-generator-wasm/src/lib.rs

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
//! avoids a direct dependency on `cedar-policy-core` in the bindings crate.
2626
2727
use cedar_policy_mcp_schema_generator::{SchemaGenerator, SchemaGeneratorConfig};
28+
use mcp_tools_sdk::data::Input;
2829
use mcp_tools_sdk::description::ServerDescription;
2930
use serde::{Deserialize, Serialize};
3031
use wasm_bindgen::prelude::*;
@@ -122,6 +123,196 @@ pub fn generate_schema(
122123
})
123124
}
124125

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+
125316
/// Convenience constructor for error results.
126317
fn err_result(error: String) -> WasmSchemaResult {
127318
WasmSchemaResult {
@@ -719,3 +910,181 @@ mod coverage_tests {
719910
assert!(result.is_ok);
720911
}
721912
}
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+
}

rust/cedar-policy-mcp-schema-generator/src/generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ mod request;
44
mod schema;
55

66
pub use err::{RequestGeneratorError, SchemaGeneratorError};
7-
pub use request::RequestGenerator;
7+
pub use request::{AuthorizationComponents, RequestGenerator};
88
pub use schema::{SchemaGenerator, SchemaGeneratorConfig};

0 commit comments

Comments
 (0)