|
| 1 | +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; |
1 | 2 | use chrono::Utc; |
2 | 3 | use clap::{Args, Parser, Subcommand}; |
3 | 4 | use serde::Deserialize; |
4 | 5 | use serde_json::{json, Value}; |
5 | 6 | use sparsekernel_core::{ |
6 | | - CapabilityCheck, EnqueueTaskInput, GrantCapabilityInput, SparseKernelDb, SparseKernelPaths, |
| 7 | + ArtifactStore, CapabilityCheck, EnqueueTaskInput, GrantCapabilityInput, SparseKernelDb, |
| 8 | + SparseKernelPaths, |
7 | 9 | }; |
8 | 10 | use std::error::Error; |
9 | 11 | use std::net::ToSocketAddrs; |
| 12 | +use std::path::{Path, PathBuf}; |
10 | 13 | use tiny_http::{Header, Response, Server}; |
11 | 14 |
|
12 | 15 | #[derive(Debug, Parser)] |
@@ -261,6 +264,78 @@ pub fn handle_api_request( |
261 | 264 | method: &str, |
262 | 265 | url: &str, |
263 | 266 | body: &[u8], |
| 267 | +) -> Result<ApiReply, Box<dyn Error>> { |
| 268 | + handle_api_request_with_artifact_root(db, method, url, body, None) |
| 269 | +} |
| 270 | + |
| 271 | +#[derive(Debug, Deserialize)] |
| 272 | +struct ArtifactSubject { |
| 273 | + subject_type: String, |
| 274 | + subject_id: String, |
| 275 | + permission: Option<String>, |
| 276 | +} |
| 277 | + |
| 278 | +#[derive(Debug, Deserialize)] |
| 279 | +struct CreateArtifactRequest { |
| 280 | + content_base64: Option<String>, |
| 281 | + content_text: Option<String>, |
| 282 | + mime_type: Option<String>, |
| 283 | + retention_policy: Option<String>, |
| 284 | + subject: Option<ArtifactSubject>, |
| 285 | +} |
| 286 | + |
| 287 | +#[derive(Debug, Deserialize)] |
| 288 | +struct ArtifactAccessRequest { |
| 289 | + id: String, |
| 290 | + subject: Option<ArtifactSubject>, |
| 291 | +} |
| 292 | + |
| 293 | +fn artifact_root_path(override_root: Option<&Path>) -> PathBuf { |
| 294 | + override_root |
| 295 | + .map(Path::to_path_buf) |
| 296 | + .unwrap_or_else(|| SparseKernelPaths::from_env().artifact_root) |
| 297 | +} |
| 298 | + |
| 299 | +fn decode_artifact_content(input: &CreateArtifactRequest) -> Result<Vec<u8>, Box<dyn Error>> { |
| 300 | + match (&input.content_base64, &input.content_text) { |
| 301 | + (Some(_), Some(_)) => Err("pass only one of content_base64 or content_text".into()), |
| 302 | + (Some(raw), None) => Ok(BASE64_STANDARD.decode(raw)?), |
| 303 | + (None, Some(text)) => Ok(text.as_bytes().to_vec()), |
| 304 | + (None, None) => Err("content_base64 or content_text is required".into()), |
| 305 | + } |
| 306 | +} |
| 307 | + |
| 308 | +fn require_artifact_capability( |
| 309 | + db: &SparseKernelDb, |
| 310 | + subject: &ArtifactSubject, |
| 311 | + artifact_id: Option<&str>, |
| 312 | + action: &str, |
| 313 | +) -> Result<(), Box<dyn Error>> { |
| 314 | + let allowed = db.check_capability(CapabilityCheck { |
| 315 | + subject_type: subject.subject_type.clone(), |
| 316 | + subject_id: subject.subject_id.clone(), |
| 317 | + resource_type: "artifact".to_string(), |
| 318 | + resource_id: artifact_id.map(str::to_string), |
| 319 | + action: action.to_string(), |
| 320 | + context: None, |
| 321 | + audit_denied: true, |
| 322 | + })?; |
| 323 | + if allowed { |
| 324 | + return Ok(()); |
| 325 | + } |
| 326 | + Err(format!( |
| 327 | + "{} {} lacks artifact {action} capability", |
| 328 | + subject.subject_type, subject.subject_id |
| 329 | + ) |
| 330 | + .into()) |
| 331 | +} |
| 332 | + |
| 333 | +pub fn handle_api_request_with_artifact_root( |
| 334 | + db: &mut SparseKernelDb, |
| 335 | + method: &str, |
| 336 | + url: &str, |
| 337 | + body: &[u8], |
| 338 | + artifact_root: Option<&Path>, |
264 | 339 | ) -> Result<ApiReply, Box<dyn Error>> { |
265 | 340 | let reply = match (method, url) { |
266 | 341 | ("GET", "/health") => ApiReply { |
@@ -375,6 +450,78 @@ pub fn handle_api_request( |
375 | 450 | )?, |
376 | 451 | } |
377 | 452 | } |
| 453 | + ("POST", "/artifacts/create") => { |
| 454 | + let input: CreateArtifactRequest = parse_body(body)?; |
| 455 | + let bytes = decode_artifact_content(&input)?; |
| 456 | + if let Some(subject) = &input.subject { |
| 457 | + require_artifact_capability(db, subject, None, "write")?; |
| 458 | + } |
| 459 | + let store = ArtifactStore::new(db, artifact_root_path(artifact_root)); |
| 460 | + let subject = input.subject.as_ref().map(|subject| { |
| 461 | + ( |
| 462 | + subject.subject_type.as_str(), |
| 463 | + subject.subject_id.as_str(), |
| 464 | + subject.permission.as_deref().unwrap_or("read"), |
| 465 | + ) |
| 466 | + }); |
| 467 | + ApiReply { |
| 468 | + status_code: 200, |
| 469 | + body: serde_json::to_value(store.write( |
| 470 | + &bytes, |
| 471 | + input.mime_type.as_deref(), |
| 472 | + input.retention_policy.as_deref(), |
| 473 | + subject, |
| 474 | + )?)?, |
| 475 | + } |
| 476 | + } |
| 477 | + ("POST", "/artifacts/read") => { |
| 478 | + let input: ArtifactAccessRequest = parse_body(body)?; |
| 479 | + if let Some(subject) = &input.subject { |
| 480 | + require_artifact_capability(db, subject, Some(&input.id), "read")?; |
| 481 | + } |
| 482 | + let store = ArtifactStore::new(db, artifact_root_path(artifact_root)); |
| 483 | + let subject = input.subject.as_ref().map(|subject| { |
| 484 | + ( |
| 485 | + subject.subject_type.as_str(), |
| 486 | + subject.subject_id.as_str(), |
| 487 | + subject.permission.as_deref().unwrap_or("read"), |
| 488 | + ) |
| 489 | + }); |
| 490 | + let bytes = store.read(&input.id, subject)?; |
| 491 | + ApiReply { |
| 492 | + status_code: 200, |
| 493 | + body: json!({ |
| 494 | + "artifact": db.get_artifact(&input.id)?, |
| 495 | + "content_base64": BASE64_STANDARD.encode(bytes), |
| 496 | + }), |
| 497 | + } |
| 498 | + } |
| 499 | + ("POST", "/artifacts/metadata") => { |
| 500 | + let input: ArtifactAccessRequest = parse_body(body)?; |
| 501 | + if let Some(subject) = &input.subject { |
| 502 | + require_artifact_capability(db, subject, Some(&input.id), "read")?; |
| 503 | + if !db.has_artifact_access( |
| 504 | + &input.id, |
| 505 | + &subject.subject_type, |
| 506 | + &subject.subject_id, |
| 507 | + subject.permission.as_deref().unwrap_or("read"), |
| 508 | + )? { |
| 509 | + db.record_audit(sparsekernel_core::AuditInput { |
| 510 | + actor_type: Some(subject.subject_type.clone()), |
| 511 | + actor_id: Some(subject.subject_id.clone()), |
| 512 | + action: "artifact_access.denied".to_string(), |
| 513 | + object_type: Some("artifact".to_string()), |
| 514 | + object_id: Some(input.id.clone()), |
| 515 | + payload: Some(json!({ "permission": subject.permission.as_deref().unwrap_or("read") })), |
| 516 | + })?; |
| 517 | + return Err(format!("artifact access denied: {}", input.id).into()); |
| 518 | + } |
| 519 | + } |
| 520 | + ApiReply { |
| 521 | + status_code: 200, |
| 522 | + body: serde_json::to_value(db.get_artifact(&input.id)?)?, |
| 523 | + } |
| 524 | + } |
378 | 525 | ("GET", "/audit") => ApiReply { |
379 | 526 | status_code: 200, |
380 | 527 | body: serde_json::to_value(db.list_audit(100)?)?, |
@@ -431,6 +578,24 @@ mod tests { |
431 | 578 | .body |
432 | 579 | } |
433 | 580 |
|
| 581 | + fn json_call_with_artifact_root( |
| 582 | + db: &mut SparseKernelDb, |
| 583 | + artifact_root: &Path, |
| 584 | + method: &str, |
| 585 | + url: &str, |
| 586 | + body: Value, |
| 587 | + ) -> Value { |
| 588 | + handle_api_request_with_artifact_root( |
| 589 | + db, |
| 590 | + method, |
| 591 | + url, |
| 592 | + serde_json::to_string(&body).unwrap().as_bytes(), |
| 593 | + Some(artifact_root), |
| 594 | + ) |
| 595 | + .unwrap() |
| 596 | + .body |
| 597 | + } |
| 598 | + |
434 | 599 | #[test] |
435 | 600 | fn task_api_enqueues_claims_and_completes() { |
436 | 601 | let mut db = SparseKernelDb::open(":memory:").unwrap(); |
@@ -516,4 +681,100 @@ mod tests { |
516 | 681 | ); |
517 | 682 | assert_eq!(revoked["revoked"], true); |
518 | 683 | } |
| 684 | + |
| 685 | + #[test] |
| 686 | + fn artifact_api_creates_reads_and_checks_access() { |
| 687 | + let mut db = SparseKernelDb::open(":memory:").unwrap(); |
| 688 | + let root = tempfile::tempdir().unwrap(); |
| 689 | + db.grant_capability(GrantCapabilityInput { |
| 690 | + subject_type: "agent".to_string(), |
| 691 | + subject_id: "main".to_string(), |
| 692 | + resource_type: "artifact".to_string(), |
| 693 | + resource_id: None, |
| 694 | + action: "write".to_string(), |
| 695 | + constraints: None, |
| 696 | + expires_at: None, |
| 697 | + }) |
| 698 | + .unwrap(); |
| 699 | + |
| 700 | + let created = json_call_with_artifact_root( |
| 701 | + &mut db, |
| 702 | + root.path(), |
| 703 | + "POST", |
| 704 | + "/artifacts/create", |
| 705 | + json!({ |
| 706 | + "content_text": "hello", |
| 707 | + "mime_type": "text/plain", |
| 708 | + "retention_policy": "session", |
| 709 | + "subject": { |
| 710 | + "subject_type": "agent", |
| 711 | + "subject_id": "main", |
| 712 | + "permission": "read", |
| 713 | + }, |
| 714 | + }), |
| 715 | + ); |
| 716 | + let artifact_id = created["id"].as_str().unwrap().to_string(); |
| 717 | + let read_without_capability = handle_api_request_with_artifact_root( |
| 718 | + &mut db, |
| 719 | + "POST", |
| 720 | + "/artifacts/read", |
| 721 | + serde_json::to_string(&json!({ |
| 722 | + "id": artifact_id, |
| 723 | + "subject": { |
| 724 | + "subject_type": "agent", |
| 725 | + "subject_id": "main", |
| 726 | + "permission": "read", |
| 727 | + }, |
| 728 | + })) |
| 729 | + .unwrap() |
| 730 | + .as_bytes(), |
| 731 | + Some(root.path()), |
| 732 | + ); |
| 733 | + assert!(read_without_capability.is_err()); |
| 734 | + |
| 735 | + db.grant_capability(GrantCapabilityInput { |
| 736 | + subject_type: "agent".to_string(), |
| 737 | + subject_id: "main".to_string(), |
| 738 | + resource_type: "artifact".to_string(), |
| 739 | + resource_id: Some(artifact_id.clone()), |
| 740 | + action: "read".to_string(), |
| 741 | + constraints: None, |
| 742 | + expires_at: None, |
| 743 | + }) |
| 744 | + .unwrap(); |
| 745 | + let metadata = json_call_with_artifact_root( |
| 746 | + &mut db, |
| 747 | + root.path(), |
| 748 | + "POST", |
| 749 | + "/artifacts/metadata", |
| 750 | + json!({ |
| 751 | + "id": artifact_id, |
| 752 | + "subject": { |
| 753 | + "subject_type": "agent", |
| 754 | + "subject_id": "main", |
| 755 | + "permission": "read", |
| 756 | + }, |
| 757 | + }), |
| 758 | + ); |
| 759 | + assert_eq!(metadata["mime_type"], "text/plain"); |
| 760 | + |
| 761 | + let read = json_call_with_artifact_root( |
| 762 | + &mut db, |
| 763 | + root.path(), |
| 764 | + "POST", |
| 765 | + "/artifacts/read", |
| 766 | + json!({ |
| 767 | + "id": metadata["id"], |
| 768 | + "subject": { |
| 769 | + "subject_type": "agent", |
| 770 | + "subject_id": "main", |
| 771 | + "permission": "read", |
| 772 | + }, |
| 773 | + }), |
| 774 | + ); |
| 775 | + let decoded = BASE64_STANDARD |
| 776 | + .decode(read["content_base64"].as_str().unwrap()) |
| 777 | + .unwrap(); |
| 778 | + assert_eq!(decoded, b"hello"); |
| 779 | + } |
519 | 780 | } |
0 commit comments