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<String> compiled cleanly for wasm32-unknown-unknown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
@@ -206,18 +206,27 @@ impl OrgManifest {
|
|||||||
Self { schema_version: 1, entries: Vec::new() }
|
Self { schema_version: 1, entries: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return only entries whose collection is in `member.collections`.
|
/// Keep only entries whose collection slug is in `granted`.
|
||||||
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
|
///
|
||||||
let granted: std::collections::HashSet<&str> =
|
/// NOTE: phase-1 grant filtering is a SOFT/UX filter — the org member holds
|
||||||
member.collections.iter().map(|s| s.as_str()).collect();
|
/// 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 {
|
Self {
|
||||||
schema_version: self.schema_version,
|
schema_version: self.schema_version,
|
||||||
entries: self.entries.iter()
|
entries: self.entries.iter()
|
||||||
.filter(|e| granted.contains(e.collection.as_str()))
|
.filter(|e| set.contains(e.collection.as_str()))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect(),
|
.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 {
|
impl Default for OrgManifest {
|
||||||
@@ -418,6 +427,44 @@ mod tests {
|
|||||||
assert_eq!(filtered.entries[0].collection, "prod");
|
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]
|
#[test]
|
||||||
fn generate_org_key_is_32_bytes() {
|
fn generate_org_key_is_32_bytes() {
|
||||||
let key = generate_org_key();
|
let key = generate_org_key();
|
||||||
|
|||||||
@@ -672,6 +672,26 @@ pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<
|
|||||||
js_value_for(&out)
|
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<String>,
|
||||||
|
) -> Result<JsValue, JsError> {
|
||||||
|
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
|
/// 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).
|
/// (Dev-C never touches WASM — this is exposed here so they don't have to).
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
8
extension/src/wasm.d.ts
vendored
8
extension/src/wasm.d.ts
vendored
@@ -91,6 +91,14 @@ declare module 'relicario-wasm' {
|
|||||||
export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle;
|
export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle;
|
||||||
export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||||
export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
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.
|
* Encrypt the registered device key under the vault master key.
|
||||||
* Returns CIPHERTEXT — the device private key never crosses to JS.
|
* Returns CIPHERTEXT — the device private key never crosses to JS.
|
||||||
|
|||||||
Reference in New Issue
Block a user