From 288956c08951be416c547356f79a8e4f62eb96ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:19:55 -0400 Subject: [PATCH] feat(core,wasm): filter_by_collections single-source + org_manifest_decrypt_filtered Add OrgManifest::filter_by_collections(&[String]) as the single source of grant-filter logic. Refactor filter_for_member to delegate to it (no duplicated loop). Add org_manifest_decrypt_filtered WASM binding that decrypts + filters in core so ungranted entries never cross to JS (phase-1 SOFT/UX filter; per-collection crypto isolation is phase-2). Declare the binding in extension/src/wasm.d.ts. Vec compiled cleanly for wasm32-unknown-unknown. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- crates/relicario-core/src/org.rs | 57 +++++++++++++++++++++++++++++--- crates/relicario-wasm/src/lib.rs | 20 +++++++++++ extension/src/wasm.d.ts | 8 +++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/relicario-core/src/org.rs b/crates/relicario-core/src/org.rs index caceaf2..43094ef 100644 --- a/crates/relicario-core/src/org.rs +++ b/crates/relicario-core/src/org.rs @@ -206,18 +206,27 @@ impl OrgManifest { Self { schema_version: 1, entries: Vec::new() } } - /// Return only entries whose collection is in `member.collections`. - pub fn filter_for_member(&self, member: &OrgMember) -> Self { - let granted: std::collections::HashSet<&str> = - member.collections.iter().map(|s| s.as_str()).collect(); + /// Keep only entries whose collection slug is in `granted`. + /// + /// NOTE: phase-1 grant filtering is a SOFT/UX filter — the org member holds + /// the single org key and can decrypt everything; per-collection crypto + /// isolation is phase-2. This hides ungranted entries from the UI; it is not + /// a cryptographic boundary. + pub fn filter_by_collections(&self, granted: &[String]) -> Self { + let set: std::collections::HashSet<&str> = granted.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() - .filter(|e| granted.contains(e.collection.as_str())) + .filter(|e| set.contains(e.collection.as_str())) .cloned() .collect(), } } + + /// Return only entries whose collection is in `member.collections`. + pub fn filter_for_member(&self, member: &OrgMember) -> Self { + self.filter_by_collections(&member.collections) // delegate — single source + } } impl Default for OrgManifest { @@ -418,6 +427,44 @@ mod tests { assert_eq!(filtered.entries[0].collection, "prod"); } + #[test] + fn filter_by_collections_keeps_only_granted_entries() { + let mut manifest = OrgManifest::new(); + for (title, coll) in [("A", "prod"), ("B", "dev"), ("C", "secret")] { + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: title.into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: coll.into(), + }); + } + + let filtered = manifest.filter_by_collections(&["prod".into(), "dev".into()]); + assert_eq!(filtered.entries.len(), 2); + assert!(filtered.entries.iter().all(|e| e.collection != "secret")); + assert_eq!(filtered.schema_version, manifest.schema_version); + } + + #[test] + fn filter_by_collections_empty_granted_returns_empty() { + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: "X".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + + let filtered = manifest.filter_by_collections(&[]); + assert_eq!(filtered.entries.len(), 0); + } + #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index ebb7215..1b412c2 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -672,6 +672,26 @@ pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result< js_value_for(&out) } +/// Decrypt an org manifest blob AND grant-filter it to `granted` collection slugs, +/// for the READ path. Filtering happens in core (`filter_by_collections`), so +/// ungranted entries never cross to JS. (SOFT phase-1 UX filter — see core +/// doc-comment; the member holds the org key, this is not crypto isolation.) +/// Dev-C writes use the UNFILTERED `org_manifest_decrypt` instead (re-encrypting a +/// filtered subset would wipe ungranted entries). +#[wasm_bindgen] +pub fn org_manifest_decrypt_filtered( + handle: &SessionHandle, + encrypted: &[u8], + granted: Vec, +) -> Result { + need_key(handle)?; + let manifest = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let filtered = manifest.filter_by_collections(&granted); + js_value_for(&filtered) +} + /// Encrypt an OrgManifest (JSON) with an org session handle, for the write track /// (Dev-C never touches WASM — this is exposed here so they don't have to). #[wasm_bindgen] diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index cad0669..9bd58a1 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -91,6 +91,14 @@ declare module 'relicario-wasm' { export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle; export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; + /** + * Decrypt an org manifest AND filter it to the granted collection slugs. + * Filtering happens in core (SOFT/UX filter — phase-1 only; the member holds + * the org key and can decrypt everything; per-collection crypto isolation is + * phase-2). Use for READ paths only; writes must use org_manifest_decrypt to + * avoid wiping ungranted collections on re-encrypt. + */ + export function org_manifest_decrypt_filtered(handle: SessionHandle, encrypted: Uint8Array, granted: string[]): unknown; /** * Encrypt the registered device key under the vault master key. * Returns CIPHERTEXT — the device private key never crosses to JS.