From c0559fa2515898cd3d836a36806ceb415eeb03a6 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 10:41:44 +0900 Subject: [PATCH 1/7] Introduce canonical content history and scoped transaction foundation --- Cargo.lock | 29 ++ Cargo.toml | 9 + crates/content-history-adapters/Cargo.toml | 11 + crates/content-history-adapters/README.md | 9 + crates/content-history-adapters/src/lib.rs | 159 +++++++++ .../content-history-adapters/src/settings.rs | 198 ++++++++++ crates/content-history-adapters/src/ticket.rs | 141 ++++++++ crates/content-history-store/Cargo.toml | 16 + crates/content-history-store/README.md | 38 ++ crates/content-history-store/src/lib.rs | 248 +++++++++++++ crates/content-history-store/src/schema.sql | 32 ++ crates/content-history/Cargo.toml | 14 + crates/content-history/README.md | 65 ++++ .../content-history/examples/wasm_parity.rs | 13 + crates/content-history/src/lib.rs | 13 + crates/content-history/src/model.rs | 337 ++++++++++++++++++ crates/content-history/src/traversal.rs | 315 ++++++++++++++++ 17 files changed, 1647 insertions(+) create mode 100644 crates/content-history-adapters/Cargo.toml create mode 100644 crates/content-history-adapters/README.md create mode 100644 crates/content-history-adapters/src/lib.rs create mode 100644 crates/content-history-adapters/src/settings.rs create mode 100644 crates/content-history-adapters/src/ticket.rs create mode 100644 crates/content-history-store/Cargo.toml create mode 100644 crates/content-history-store/README.md create mode 100644 crates/content-history-store/src/lib.rs create mode 100644 crates/content-history-store/src/schema.sql create mode 100644 crates/content-history/Cargo.toml create mode 100644 crates/content-history/README.md create mode 100644 crates/content-history/examples/wasm_parity.rs create mode 100644 crates/content-history/src/lib.rs create mode 100644 crates/content-history/src/model.rs create mode 100644 crates/content-history/src/traversal.rs diff --git a/Cargo.lock b/Cargo.lock index 30a5d87ca..b5edee248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,6 +837,35 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "content-history" +version = "0.1.0" +dependencies = [ + "sha2 0.11.0", + "thiserror 2.0.18", +] + +[[package]] +name = "content-history-adapters" +version = "0.1.0" +dependencies = [ + "config-source", + "content-history", + "thiserror 2.0.18", +] + +[[package]] +name = "content-history-store" +version = "0.1.0" +dependencies = [ + "config-source", + "content-history", + "content-history-adapters", + "rusqlite", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "convert_case" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index da9875613..6fa60d16a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ members = [ "crates/builtin-source", "crates/config-source", "crates/config-source-wasm", + "crates/content-history", + "crates/content-history-store", + "crates/content-history-adapters", "crates/workdir", "crates/tui", "crates/memory", @@ -74,6 +77,9 @@ default-members = [ "crates/builtin-source", "crates/config-source", "crates/config-source-wasm", + "crates/content-history", + "crates/content-history-store", + "crates/content-history-adapters", "crates/workdir", "crates/tui", "crates/memory", @@ -140,6 +146,9 @@ secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } builtin-source = { path = "crates/builtin-source" } config-source = { path = "crates/config-source" } +content-history = { path = "crates/content-history" } +content-history-store = { path = "crates/content-history-store" } +content-history-adapters = { path = "crates/content-history-adapters" } error-contract = { path = "crates/error-contract" } fs-operation = { path = "crates/fs-operation" } workdir = { path = "crates/workdir" } diff --git a/crates/content-history-adapters/Cargo.toml b/crates/content-history-adapters/Cargo.toml new file mode 100644 index 000000000..509ebf29f --- /dev/null +++ b/crates/content-history-adapters/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "content-history-adapters" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +content-history.workspace = true +config-source.workspace = true +thiserror.workspace = true diff --git a/crates/content-history-adapters/README.md b/crates/content-history-adapters/README.md new file mode 100644 index 000000000..8161ff84f --- /dev/null +++ b/crates/content-history-adapters/README.md @@ -0,0 +1,9 @@ +# content-history-adapters + +Contract examples mapping configuration sources and Ticket compound documents onto the independent content-history model. These adapters prepare immutable candidates and restore complete content; they do not save, authorize, evaluate, adopt, or migrate business data. + +- [`settings`] preserves virtual paths, full UTF-8 sources, and explicit entry metadata. +- [`ticket`] separates document content and original authorship from business state and edit history. +- [`Snapshot`] supplies a candidate root and objects for caller-owned store preparation. A successful preparation is not business adoption; the caller must validate and atomically adopt source, projection, and history head in its own transaction. + +Readers must be scoped to an authorized object/root by the store. Knowing a hash is not authorization. Missing or erased content must fail restoration rather than be reconstructed from another scope. Retention, explicit erasure, expected-history-head CAS, and new authenticated editor history on restoration belong to the store and caller, not these examples. diff --git a/crates/content-history-adapters/src/lib.rs b/crates/content-history-adapters/src/lib.rs new file mode 100644 index 000000000..6ddbb0f1b --- /dev/null +++ b/crates/content-history-adapters/src/lib.rs @@ -0,0 +1,159 @@ +#![doc = include_str!("../README.md")] + +use std::collections::BTreeMap; + +use content_history::{BlobId, ContentId, Entry, Object, ObjectId, Tree, TreeId}; + +pub mod settings; +pub mod ticket; + +/// Complete immutable candidate for store preparation, not a saved or adopted version. +#[derive(Debug, Clone)] +pub struct Snapshot { + pub root: TreeId, + pub objects: BTreeMap, +} + +impl Snapshot { + /// In-memory reader for examples. Production readers must enforce authorization and erasure. + pub fn get(&self, id: ObjectId) -> Result { + self.objects.get(&id).cloned().ok_or(Error::Missing(id)) + } +} + +/// Restoration fails closed: no partial document is returned on any read or contract error. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Model(#[from] content_history::Error), + #[error(transparent)] + Config(#[from] config_source::ConfigTreeError), + #[error("missing object: {0:?}")] + Missing(ObjectId), + #[error("reader returned an object not matching requested identity: {0:?}")] + HashMismatch(ObjectId), + #[error("invalid adapter structure: {0}")] + Structure(&'static str), + #[error("unsupported adapter format")] + Format, + #[error("content is not UTF-8")] + Utf8(#[from] std::string::FromUtf8Error), + #[error("file/directory conflict: {0}")] + PathConflict(String), + #[error("Ticket identity does not match the restoration target")] + TicketIdentity, + #[error("authorized reader failed: {0}")] + Read(String), +} + +#[derive(Default)] +struct Builder { + objects: BTreeMap, +} + +impl Builder { + fn blob(&mut self, bytes: impl AsRef<[u8]>) -> ContentId { + let object = Object::Blob(bytes.as_ref().to_vec()); + let ObjectId::Blob(id) = object.id() else { + unreachable!() + }; + self.objects.insert(object.id(), object); + ContentId::Blob(id) + } + + fn tree(&mut self, tree: Tree) -> TreeId { + let object = Object::Tree(tree); + let ObjectId::Tree(id) = object.id() else { + unreachable!() + }; + self.objects.insert(object.id(), object); + id + } + + fn map(&mut self, entries: Vec) -> Result { + Ok(self.tree(Tree::map(entries)?)) + } + + fn finish(self, root: TreeId) -> Snapshot { + Snapshot { + root, + objects: self.objects, + } + } +} + +fn entry(key: impl Into, target: ContentId) -> Entry { + Entry { + key: key.into(), + target, + } +} + +fn read_object( + read: &mut impl FnMut(ObjectId) -> Result, + id: ObjectId, +) -> Result { + let object = read(id)?; + if object.id() != id { + return Err(Error::HashMismatch(id)); + } + Ok(object) +} + +fn read_tree( + read: &mut impl FnMut(ObjectId) -> Result, + id: TreeId, + ordered: bool, +) -> Result { + let Object::Tree(tree) = read_object(read, ObjectId::Tree(id))? else { + return Err(Error::Structure("expected tree")); + }; + if tree.is_ordered() != ordered { + return Err(Error::Structure("incorrect tree ordering")); + } + Ok(tree) +} + +fn tree_id(target: ContentId) -> Result { + match target { + ContentId::Tree(id) => Ok(id), + _ => Err(Error::Structure("expected tree target")), + } +} + +fn blob_id(target: ContentId) -> Result { + match target { + ContentId::Blob(id) => Ok(id), + _ => Err(Error::Structure("expected blob target")), + } +} + +fn text( + read: &mut impl FnMut(ObjectId) -> Result, + target: ContentId, +) -> Result { + let Object::Blob(bytes) = read_object(read, ObjectId::Blob(blob_id(target)?))? else { + return Err(Error::Structure("expected blob")); + }; + Ok(String::from_utf8(bytes)?) +} + +fn fields(tree: &Tree, expected: &[&str]) -> Result<(), Error> { + if tree.is_ordered() + || tree.entries().len() != expected.len() + || expected + .iter() + .any(|key| !tree.entries().iter().any(|e| e.key == *key)) + { + return Err(Error::Structure("unexpected fields")); + } + Ok(()) +} + +fn field(tree: &Tree, key: &str) -> Result { + tree.entries() + .iter() + .find(|e| e.key == key) + .map(|e| e.target) + .ok_or(Error::Structure("missing field")) +} diff --git a/crates/content-history-adapters/src/settings.rs b/crates/content-history-adapters/src/settings.rs new file mode 100644 index 000000000..bd0cea662 --- /dev/null +++ b/crates/content-history-adapters/src/settings.rs @@ -0,0 +1,198 @@ +//! Configuration content is a real directory/name Merkle hierarchy with full-source blob leaves. +//! +//! The versioned root separates `files` from `types`: explicit content types are keyed by full +//! virtual path in `types`. A type change therefore changes the root without changing source bytes. +//! Types must not be guessed from suffixes: [`ConfigEntry`] permits explicit metadata independent +//! of a filename. Digests are reconstructed through config-source, never substituted by Merkle IDs. +//! +//! Preparation validates the original snapshot, then rejects file/directory prefix conflicts that +//! a flat snapshot can represent but a directory tree cannot. Restoration rejects empty non-root +//! directories, malformed paths, unknown types, surplus/missing metadata, invalid UTF-8 and limits. +//! Both directions use existing config-source validation; neither parses nor normalizes Decodal. +//! Evaluation, policy checks and transactional adoption remain the caller's responsibility. + +use std::collections::BTreeMap; + +use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, MAX_ENTRY_COUNT, MAX_PATH_BYTES, + MAX_TOTAL_BYTES, VirtualPath, +}; +use content_history::{ContentId, Object, ObjectId, TreeId}; + +use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id}; + +const FORMAT: &str = "config-tree/v1"; + +#[derive(Default)] +struct Directory { + children: BTreeMap, +} + +enum Node { + File(ContentId), + Directory(Directory), +} + +impl Directory { + fn insert(&mut self, parts: &[&str], target: ContentId, path: &str) -> Result<(), Error> { + let name = parts[0]; + if parts.len() == 1 { + if self + .children + .insert(name.into(), Node::File(target)) + .is_some() + { + return Err(Error::PathConflict(path.into())); + } + } else { + let child = self + .children + .entry(name.into()) + .or_insert_with(|| Node::Directory(Directory::default())); + match child { + Node::Directory(directory) => directory.insert(&parts[1..], target, path)?, + Node::File(_) => return Err(Error::PathConflict(path.into())), + } + } + Ok(()) + } + + fn build(self, builder: &mut Builder) -> Result { + let mut entries = Vec::new(); + for (name, node) in self.children { + let target = match node { + Node::File(target) => target, + Node::Directory(directory) => ContentId::Tree(directory.build(builder)?), + }; + entries.push(entry(name, target)); + } + builder.map(entries) + } +} + +/// Prepare objects only after validating all original snapshot metadata. +pub fn prepare(snapshot: &ConfigTreeSnapshot) -> Result { + snapshot.validate()?; + let mut builder = Builder::default(); + let mut directory = Directory::default(); + let mut types = Vec::new(); + for (path, source) in &snapshot.entries { + directory.insert( + &path.as_str().split('/').collect::>(), + builder.blob(&source.content), + path.as_str(), + )?; + let content_type = match source.content_type { + ConfigContentType::Decodal => "decodal", + ConfigContentType::Text => "text", + }; + types.push(entry(path.as_str(), builder.blob(content_type))); + } + let files = directory.build(&mut builder)?; + let types = builder.map(types)?; + let format = builder.blob(FORMAT); + let root = builder.map(vec![ + entry("format", format), + entry("files", ContentId::Tree(files)), + entry("types", ContentId::Tree(types)), + ])?; + Ok(builder.finish(root)) +} + +/// Read through an authorized reader and reconstruct validated ConfigEntry metadata and digests. +/// The returned snapshot is a candidate, not a validated evaluation or an applied configuration. +pub fn restore( + root: TreeId, + mut read: impl FnMut(ObjectId) -> Result, +) -> Result { + let root = read_tree(&mut read, root, false)?; + fields(&root, &["format", "files", "types"])?; + if text(&mut read, field(&root, "format")?)? != FORMAT { + return Err(Error::Format); + } + let types = read_tree(&mut read, tree_id(field(&root, "types")?)?, false)?; + if types.entries().len() > MAX_ENTRY_COUNT { + return Err(Error::Structure("too many config types")); + } + let mut metadata = BTreeMap::new(); + for item in types.entries() { + let path = VirtualPath::parse(&item.key)?; + let kind = match text(&mut read, item.target)?.as_str() { + "decodal" => ConfigContentType::Decodal, + "text" => ConfigContentType::Text, + _ => return Err(Error::Structure("unknown config content type")), + }; + metadata.insert(path, kind); + } + let mut entries = Vec::new(); + let mut total = 0; + restore_directory( + tree_id(field(&root, "files")?)?, + "", + &mut read, + &mut metadata, + &mut entries, + &mut total, + )?; + if !metadata.is_empty() { + return Err(Error::Structure("surplus config metadata")); + } + Ok(ConfigTreeSnapshot::from_entries(entries)?) +} + +fn restore_directory( + id: TreeId, + prefix: &str, + read: &mut impl FnMut(ObjectId) -> Result, + metadata: &mut BTreeMap, + entries: &mut Vec, + total: &mut usize, +) -> Result<(), Error> { + let directory = read_tree(read, id, false)?; + if !prefix.is_empty() && directory.entries().is_empty() { + return Err(Error::Structure("empty non-root config directory")); + } + // Limit directory fanout and path length even for malformed externally supplied trees. + if directory.entries().len() > MAX_ENTRY_COUNT { + return Err(Error::Structure("too many config children")); + } + for item in directory.entries() { + if item.key.contains('/') { + return Err(Error::Structure("config child must be one path component")); + } + let path = if prefix.is_empty() { + item.key.clone() + } else { + format!("{prefix}/{}", item.key) + }; + if path.len() > MAX_PATH_BYTES { + return Err(Error::Structure("config path too long")); + } + let path = VirtualPath::parse(path)?; + match item.target { + ContentId::Tree(child) => { + // A valid directory must lead to an as-yet unrestored metadata entry. This also + // bounds traversal of adversarial shared subtrees by the existing entry/path limits. + let child_prefix = format!("{path}/"); + if !metadata.keys().any(|key| key.as_str().starts_with(&child_prefix)) { + return Err(Error::Structure("config directory has no file metadata")); + } + restore_directory(child, path.as_str(), read, metadata, entries, total)? + } + ContentId::Blob(_) => { + let kind = metadata + .remove(&path) + .ok_or(Error::Structure("missing config metadata"))?; + let source = text(read, item.target)?; + *total = total + .checked_add(source.len()) + .ok_or(Error::Structure("config bytes overflow"))?; + if *total > MAX_TOTAL_BYTES || entries.len() >= MAX_ENTRY_COUNT { + return Err(Error::Structure("config snapshot limits exceeded")); + } + entries.push(ConfigEntry::new(path, kind, source)?); + } + } + } + Ok(()) +} diff --git a/crates/content-history-adapters/src/ticket.rs b/crates/content-history-adapters/src/ticket.rs new file mode 100644 index 000000000..e56071dcd --- /dev/null +++ b/crates/content-history-adapters/src/ticket.rs @@ -0,0 +1,141 @@ +//! Ticket compound-document contract, independent of the Ticket service and business events. +//! +//! The versioned root map includes `ticket-id`, `title`, `initial` and `posts`. `initial` is a map +//! of `author` and `body`; `posts` is an ordered sequence keyed by stable post IDs, each targeting +//! an `author`/`body` map. Order is never sorted for hash stability. All text is complete UTF-8, +//! including comments, Unicode and original line endings. Empty bodies and titles are preserved. +//! +//! Ticket identity deliberately participates in content: equal text on different Tickets yields +//! different roots. It is a business identity, not a content ID. Restoration requires the expected +//! identity and cannot retarget content to a different Ticket. Original authors participate in the +//! snapshot; authenticated history editors and edit times belong to a separate History record. +//! +//! These local types exclude state, assignment, relations, evidence and the existing content_digest. +//! Restoration returns only a document candidate; it cannot rewind business events or replace the +//! existing API's CAS. A caller restoring an old root must append a new history with current parent +//! and authenticated editor facts, and adopt content under its own business authorization/CAS. + +use std::collections::BTreeSet; + +use content_history::{ContentId, Object, ObjectId, Tree, TreeId}; + +use crate::{Builder, Error, Snapshot, entry, field, fields, read_tree, text, tree_id}; + +const FORMAT: &str = "ticket-document/v1"; + +/// Content-only view. `initial_author` is the original author, not the latest history editor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Document { + pub ticket_id: String, + pub title: String, + pub initial_body: String, + pub initial_author: String, + pub posts: Vec, +} + +/// Stable business identity and original authorship survive edits and restoration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Post { + pub id: String, + pub author: String, + pub body: String, +} + +impl Document { + fn validate(&self) -> Result<(), Error> { + if self.ticket_id.is_empty() { + return Err(Error::Structure("empty Ticket identity")); + } + let mut ids = BTreeSet::new(); + for post in &self.posts { + if post.id.is_empty() || !ids.insert(&post.id) { + return Err(Error::Structure("empty or duplicate post identity")); + } + } + Ok(()) + } +} + +fn message(builder: &mut Builder, author: &str, body: &str) -> Result { + let author = builder.blob(author); + let body = builder.blob(body); + builder.map(vec![entry("author", author), entry("body", body)]) +} + +/// Prepare a candidate with explicit root components and semantically ordered posts. +pub fn prepare(document: &Document) -> Result { + document.validate()?; + let mut builder = Builder::default(); + let initial = message( + &mut builder, + &document.initial_author, + &document.initial_body, + )?; + let mut posts = Vec::new(); + for post in &document.posts { + let content = message(&mut builder, &post.author, &post.body)?; + posts.push(entry(&post.id, ContentId::Tree(content))); + } + let posts = builder.tree(Tree::sequence(posts)?); + let format = builder.blob(FORMAT); + let identity = builder.blob(&document.ticket_id); + let title = builder.blob(&document.title); + let root = builder.map(vec![ + entry("format", format), + entry("ticket-id", identity), + entry("title", title), + entry("initial", ContentId::Tree(initial)), + entry("posts", ContentId::Tree(posts)), + ])?; + Ok(builder.finish(root)) +} + +/// Restore content only and verify the intended business identity before returning any document. +pub fn restore( + root: TreeId, + expected_ticket_id: &str, + mut read: impl FnMut(ObjectId) -> Result, +) -> Result { + let root = read_tree(&mut read, root, false)?; + fields(&root, &["format", "ticket-id", "title", "initial", "posts"])?; + if text(&mut read, field(&root, "format")?)? != FORMAT { + return Err(Error::Format); + } + let ticket_id = text(&mut read, field(&root, "ticket-id")?)?; + if ticket_id != expected_ticket_id { + return Err(Error::TicketIdentity); + } + let title = text(&mut read, field(&root, "title")?)?; + let (initial_author, initial_body) = restore_message(&mut read, field(&root, "initial")?)?; + let posts = read_tree(&mut read, tree_id(field(&root, "posts")?)?, true)?; + let mut restored = Vec::new(); + for post in posts.entries() { + let (author, body) = restore_message(&mut read, post.target)?; + restored.push(Post { + id: post.key.clone(), + author, + body, + }); + } + let document = Document { + ticket_id, + title, + initial_body, + initial_author, + posts: restored, + }; + document.validate()?; + Ok(document) +} + +fn restore_message( + read: &mut impl FnMut(ObjectId) -> Result, + target: ContentId, +) -> Result<(String, String), Error> { + let message = read_tree(read, tree_id(target)?, false)?; + fields(&message, &["author", "body"])?; + Ok(( + text(read, field(&message, "author")?)?, + text(read, field(&message, "body")?)?, + )) +} diff --git a/crates/content-history-store/Cargo.toml b/crates/content-history-store/Cargo.toml new file mode 100644 index 000000000..20f72ba12 --- /dev/null +++ b/crates/content-history-store/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "content-history-store" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +content-history.workspace = true +rusqlite.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true +content-history-adapters.workspace = true +config-source.workspace = true diff --git a/crates/content-history-store/README.md b/crates/content-history-store/README.md new file mode 100644 index 000000000..aa583b507 --- /dev/null +++ b/crates/content-history-store/README.md @@ -0,0 +1,38 @@ +# content-history-store + +Persists immutable content and append-only editing history on the caller's SQLite transaction. +The pure canonical model belongs to `content-history`; this crate does not evaluate configuration, +edit Tickets, authenticate users, or replace their existing CAS contracts. + +[`ScopedStore`] is a trusted Host adapter, not an untrusted SQL service. Its caller implements +[`Authority`] using existing current operation authorization for the exact [`ScopeKey`]. Every +operation rechecks authority before reading stored metadata. A hash is never a capability: even +preparation requires complete verified bytes, and public reads require reachability from an +adopted history in that same workspace and target. Physical deduplication does not grant access. + +[`SCHEMA`] is the domain's schema contribution. The eventual adopting Server domain must install +it through its existing ordered migration and fresh-schema path, not through a second startup +migration runner. This foundation does not change the production configuration writer. The +adapter accepts the same `rusqlite::Transaction` used by the Server. The caller prepares complete +objects, validates one candidate snapshot, calls [`ScopedStore::adopt`] with the observed history +head, and updates source/projection/business records before committing that **same** transaction. +An error must roll back the caller's transaction; a successful adapter call alone is not successful +business adoption. Each individual mutating method also uses a savepoint, so catching its error +cannot retain its partial changes. SQLite writer acquisition and BUSY/retry policy remain caller-owned. +No automatic retries or external effects are hidden here. + +Normal edits retain all adopted ancestors and their full snapshots; [`ScopedStore::collect_unadopted`] +only removes preparations not used by any adopted history. Object rows cannot be overwritten +through the adapter. A supplied hash/bytes mismatch, a differing existing row, a missing child, +wrong child kind or unsupported canonical format fails rather than publishing an incomplete head. +Stored bytes are verified on every read, not reserialized under a newer version. + +[`ScopedStore::erase`] requires separate explicit authorization, tombstones the exact target +permanently and removes its ownership references. Old hashes cannot recreate that target's data. +Other targets' retained or prepared references keep shared physical objects alive. A newly created +business object needs a new target identity; erasure is not an edit or a rewind. This is logical +store erasure, not secure disk overwrite or revocation of already exported copies/backups. The +caller owns retention decisions, backup policy and authenticated editor/time facts. Restoring an +old content root is a new adoption with the **current** history parent, never resetting head to an +old history. Existing Ticket `content_digest`, Ticket/post identities and business events remain +separate contracts owned by their domain. diff --git a/crates/content-history-store/src/lib.rs b/crates/content-history-store/src/lib.rs new file mode 100644 index 000000000..4c0d15f73 --- /dev/null +++ b/crates/content-history-store/src/lib.rs @@ -0,0 +1,248 @@ +#![doc = include_str!("../README.md")] + +use std::collections::BTreeMap; + +use content_history::{History, HistoryId, Object, ObjectId, TreeId, verify}; +use rusqlite::{OptionalExtension, Transaction, params}; + +/// New domain tables, deliberately not an alternate Server migration runner. +/// The adopting domain installs these through its ordered schema migration and fresh schema. +pub const SCHEMA: &str = include_str!("schema.sql"); + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("content access denied")] + Denied, + #[error("content unavailable in authorized scope")] + Unavailable, + #[error("content scope was explicitly erased")] + Erased, + #[error("observed history head is stale")] + Conflict, + #[error("invalid content: {0}")] + Model(#[from] content_history::Error), + #[error("invalid stored history reference")] + InvalidReference, + #[error("immutable content differs from stored bytes")] + ImmutableMismatch, + #[error("sqlite content store: {0}")] + Sql(#[from] rusqlite::Error), +} +pub type Result = std::result::Result; + +/// The business owner supplies exact identities, not path containment rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopeKey { + pub workspace: String, + pub target: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + Read, + Write, + Erase, +} + +/// Existing caller authority is checked at each operation, before querying objects. +/// This is a trusted Host integration seam, not a grant constructor or authentication system. +pub trait Authority { + fn allows(&self, key: &ScopeKey, access: Access) -> bool; +} + +/// An adapter borrows the caller's real transaction; it never commits business adoption. +/// Do not expose this type or its authority implementation directly to untrusted code. +pub struct ScopedStore<'a, 'conn> { + tx: &'a Transaction<'conn>, + key: ScopeKey, + authority: &'a dyn Authority, +} + +impl<'a, 'conn> ScopedStore<'a, 'conn> { + pub fn new(tx: &'a Transaction<'conn>, key: ScopeKey, authority: &'a dyn Authority) -> Self { + Self { tx, key, authority } + } + + fn authorize(&self, access: Access) -> Result<()> { + if self.key.workspace.is_empty() || self.key.target.is_empty() + || !self.authority.allows(&self.key, access) + { + return Err(Error::Denied); + } + let erased: Option = self.tx.query_row( + "SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], |r| r.get(0), + ).optional()?; + if erased == Some(true) { return Err(Error::Erased); } + Ok(()) + } + + fn ensure_scope(&self) -> Result<()> { + self.tx.execute( + "INSERT OR IGNORE INTO content_scopes(workspace,target) VALUES(?1,?2)", + params![self.key.workspace, self.key.target], + )?; + Ok(()) + } + + pub fn head(&self) -> Result> { + self.authorize(Access::Read)?; + self.head_inner() + } + + fn head_inner(&self) -> Result> { + let raw: Option> = self.tx.query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], |r| r.get(0), + ).optional()?.flatten(); + raw.map(|v| Ok(HistoryId::from_bytes(v.try_into().map_err(|_| Error::InvalidReference)?))) + .transpose() + } + + /// Preparation requires the full canonical bytes, not possession of another scope's hash. + /// Prepared objects are not readable snapshots until adoption succeeds. + pub fn put(&self, claimed: ObjectId, bytes: &[u8]) -> Result<()> { + self.authorize(Access::Write)?; + verify(claimed, bytes)?; + self.atomic(|| self.put_inner(claimed, bytes)) + } + + fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> { + self.ensure_scope()?; + let (kind, hash) = parts(id); + self.tx.execute("INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)", + params![kind, hash, bytes])?; + let stored: Vec = self.tx.query_row( + "SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2", params![kind, hash], |r| r.get(0))?; + if stored != bytes { return Err(Error::ImmutableMismatch); } + self.tx.execute("INSERT OR IGNORE INTO content_scope_objects(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)", + params![self.key.workspace, self.key.target, kind, hash])?; + Ok(()) + } + + /// A hash read is confined to objects reachable from this target's adopted history. + /// Unknown and cross-target hashes have the same result, regardless of physical deduplication. + pub fn read(&self, id: ObjectId) -> Result { + self.authorize(Access::Read)?; + let (kind, hash) = parts(id); + let allowed: bool = self.tx.query_row( + "SELECT EXISTS(SELECT 1 FROM content_reachable WHERE workspace=?1 AND target=?2 AND kind=?3 AND hash=?4)", + params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0))?; + if !allowed { return Err(Error::Unavailable); } + self.load_prepared(id) + } + + fn load_prepared(&self, id: ObjectId) -> Result { + let (kind, hash) = parts(id); + let bytes: Option> = self.tx.query_row( + "SELECT o.bytes FROM content_objects o JOIN content_scope_objects s ON s.kind=o.kind AND s.hash=o.hash WHERE s.workspace=?1 AND s.target=?2 AND s.kind=?3 AND s.hash=?4", + params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0)).optional()?; + Ok(verify(id, &bytes.ok_or(Error::Unavailable)?)?) + } + + /// Restore uses a retained history, not a chain of text deltas. The returned graph includes + /// every full blob and structural node and fails on any missing or corrupt descendant. + pub fn snapshot(&self, history: HistoryId) -> Result> { + let Object::History(record) = self.read(ObjectId::History(history))? else { + return Err(Error::InvalidReference); + }; + self.walk([ObjectId::Tree(record.root)], true) + } + + fn walk(&self, roots: impl IntoIterator, public: bool) -> Result> { + let mut pending: Vec<_> = roots.into_iter().collect(); + let mut objects = BTreeMap::new(); + while let Some(id) = pending.pop() { + if objects.contains_key(&id) { continue; } + let object = if public { self.read(id)? } else { self.load_prepared(id)? }; + match &object { + Object::Blob(_) => {}, + Object::Tree(tree) => pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))), + Object::History(history) => { + pending.push(ObjectId::Tree(history.root)); + if let Some(parent) = history.parent { pending.push(ObjectId::History(parent)); } + } + } + objects.insert(id, object); + } + Ok(objects) + } + + /// Authenticated actor and clock facts belong to the caller, not restored post authors. + /// Restoring an old root appends a history with the current parent; no rewind operation exists. + /// The private savepoint prevents even a caught error from publishing a partial adoption. + pub fn adopt(&self, root: TreeId, expected: Option, actor: String, timestamp_micros: i64) -> Result { + self.authorize(Access::Write)?; + self.atomic(|| { + if self.head_inner()? != expected { return Err(Error::Conflict); } + let mut graph = self.walk([ObjectId::Tree(root)], false)?; + if let Some(parent) = expected { + // Validate all retained ancestors too; corruption cannot become a successful new head. + graph.extend(self.walk([ObjectId::History(parent)], false)?); + } + let history = Object::History(History { root, parent: expected, actor, timestamp_micros }); + let ObjectId::History(id) = history.id() else { unreachable!() }; + self.put_inner(history.id(), &history.encode())?; + graph.insert(history.id(), history); + for object in graph.keys() { + let (kind, hash) = parts(*object); + self.tx.execute("INSERT OR IGNORE INTO content_reachable(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)", + params![self.key.workspace, self.key.target, kind, hash])?; + } + let changed = self.tx.execute( + "UPDATE content_scopes SET head=?3 WHERE workspace=?1 AND target=?2 AND erased=0 AND head IS ?4", + params![self.key.workspace, self.key.target, id.as_bytes().as_slice(), expected.map(|h| h.as_bytes().to_vec())])?; + if changed != 1 { return Err(Error::Conflict); } + Ok(id) + }) + } + + /// Retains every adopted history. Only unadopted preparations are reclaimed. + /// Physical rows survive while *any* target has prepared or retained ownership. + pub fn collect_unadopted(&self) -> Result<()> { + self.authorize(Access::Write)?; + self.atomic(|| { + self.tx.execute("DELETE FROM content_scope_objects AS s WHERE workspace=?1 AND target=?2 AND NOT EXISTS(SELECT 1 FROM content_reachable r WHERE r.workspace=s.workspace AND r.target=s.target AND r.kind=s.kind AND r.hash=s.hash)", + params![self.key.workspace, self.key.target])?; + self.collect_physical() + }) + } + + /// Erasure is permanent for this exact business identity, including its old roots. + /// Shared bytes retained by other authorized targets are not destroyed. Backups and bytes + /// already exported by callers are outside this adapter's logical erasure guarantee. + pub fn erase(&self) -> Result<()> { + self.authorize(Access::Erase)?; + self.atomic(|| { + self.ensure_scope()?; + self.tx.execute("DELETE FROM content_reachable WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; + self.tx.execute("DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; + self.tx.execute("UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; + self.collect_physical() + }) + } + + fn collect_physical(&self) -> Result<()> { + self.tx.execute("DELETE FROM content_objects AS o WHERE NOT EXISTS(SELECT 1 FROM content_scope_objects s WHERE s.kind=o.kind AND s.hash=o.hash)", [])?; + Ok(()) + } + + fn atomic(&self, f: impl FnOnce()->Result) -> Result { + self.tx.execute_batch("SAVEPOINT content_history_operation")?; + match f() { + Ok(value) => { self.tx.execute_batch("RELEASE content_history_operation")?; Ok(value) }, + Err(error) => { + self.tx.execute_batch("ROLLBACK TO content_history_operation; RELEASE content_history_operation")?; + Err(error) + } + } + } +} + +fn parts(id: ObjectId) -> (u8, Vec) { + match id { + ObjectId::Blob(h) => (1,h.as_bytes().to_vec()), + ObjectId::Tree(h) => (2,h.as_bytes().to_vec()), + ObjectId::History(h) => (3,h.as_bytes().to_vec()), + } +} diff --git a/crates/content-history-store/src/schema.sql b/crates/content-history-store/src/schema.sql new file mode 100644 index 000000000..96e14c329 --- /dev/null +++ b/crates/content-history-store/src/schema.sql @@ -0,0 +1,32 @@ +CREATE TABLE content_objects ( + kind INTEGER NOT NULL CHECK(kind IN (1,2,3)), + hash BLOB NOT NULL CHECK(length(hash)=32), + bytes BLOB NOT NULL, + PRIMARY KEY(kind,hash) +) WITHOUT ROWID; +CREATE TABLE content_scopes ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + head BLOB CHECK(head IS NULL OR length(head)=32), + erased INTEGER NOT NULL DEFAULT 0 CHECK(erased IN (0,1)), + PRIMARY KEY(workspace,target), + CHECK(erased=0 OR head IS NULL) +) WITHOUT ROWID; +CREATE TABLE content_scope_objects ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + kind INTEGER NOT NULL, + hash BLOB NOT NULL, + PRIMARY KEY(workspace,target,kind,hash), + FOREIGN KEY(workspace,target) REFERENCES content_scopes(workspace,target), + FOREIGN KEY(kind,hash) REFERENCES content_objects(kind,hash) +) WITHOUT ROWID; +CREATE TABLE content_reachable ( + workspace TEXT NOT NULL, + target TEXT NOT NULL, + kind INTEGER NOT NULL, + hash BLOB NOT NULL, + PRIMARY KEY(workspace,target,kind,hash), + FOREIGN KEY(workspace,target,kind,hash) + REFERENCES content_scope_objects(workspace,target,kind,hash) +) WITHOUT ROWID; diff --git a/crates/content-history/Cargo.toml b/crates/content-history/Cargo.toml new file mode 100644 index 000000000..c7e962bb1 --- /dev/null +++ b/crates/content-history/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "content-history" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +sha2.workspace = true +thiserror.workspace = true + +[[example]] +name = "wasm_parity" +crate-type = ["cdylib"] diff --git a/crates/content-history/README.md b/crates/content-history/README.md new file mode 100644 index 000000000..a83dedd68 --- /dev/null +++ b/crates/content-history/README.md @@ -0,0 +1,65 @@ +# Content history + +This crate owns immutable full-text content, typed identities and pure structural +operations. It does not own persistence, authorization, retention decisions, +transaction adoption, head compare-and-set, application events or text rendering. +[`Object`] and [`Tree`] define canonical content; [`History`] separates editing +identity from content identity. [`restore`], [`diff`] and [`replace_path`] operate +through a caller-scoped [`ObjectReader`]. + +## Canonical identity and compatibility + +Version 1 hashes the entire canonical encoding using SHA-256, including object +kind and format version. Every object starts with `YCH\0`, a big-endian `u16` +version (`1`), and a kind byte (`1` blob, `2` tree, `3` history). Integer lengths +are unsigned big-endian `u64`; strings are UTF-8 prefixed by their byte length. + +- A blob contains a length and the exact full bytes. +- A tree contains a mode byte (`0` map, `1` sequence), an entry count, then each + length-prefixed key, child kind byte (`1` blob, `2` tree), and 32 hash bytes. +- A history contains 32 root hash bytes, a parent flag (`0` absent, `1` present), + optional 32 parent hash bytes, a length-prefixed actor, and a signed big-endian + `i64` timestamp in microseconds. + +Map keys are unique and sorted by exact UTF-8 byte order. Sequences also have +unique keys but preserve input order. Decoding never sorts a noncanonical map. +There is no Unicode, newline, whitespace, path or source normalization. Empty +keys and separator characters are opaque strings; adapters own path validation. +No extension fields or trailing bytes are accepted. Unsupported versions fail +explicitly: a future migration must retain old bytes and identities, not silently +reserialize old objects under a new format. [`verify`] checks kind and hash after +strict decoding. Typed IDs are identities, not evidence that an object exists. + +## Store and adapter boundary + +Readers must be bound to an authorized business target and retained root/history. +Knowing a hash must not grant access. They must refuse erased or revoked content +for that target even when shared physical bytes remain for another target. The +model cannot enforce this policy or safely implement global garbage collection. +Readers also own I/O buffer bounds; [`TraversalLimits`] bounds work after reading. +Full restoration validates every reachable object and returns no partial success. +A structural diff intentionally skips equal IDs and does not prove closure integrity. + +Path replacement returns new ancestor objects without writing anything. The +caller must validate and save the complete closure, append a history with the +current head as its parent, and CAS-adopt the new history in the same caller-owned +transaction as business projections. A rollback or stale head must leave the old +head and content unchanged. Comparing only content roots would accept stale +A-to-B-to-A edits. Restoring an old root reuses content but creates a new history; +it never rewinds business events or adopts the old history identity. + +Configuration adapters can map validated virtual path components to map entries +and exact source files to blobs. Ticket adapters can map stable document fields +to blobs and stable post IDs to a sequence; reordering posts changes content. +Ticket IDs, post IDs, existing content digests, content roots and history heads +remain distinct contracts. Neither adapter belongs in this pure model. Contract +fixtures exercise these shapes without claiming database or authorization coverage. + +## Cross-target fixture + +`tests/fixtures/v1.tsv` freezes bytes and hashes independently derived from the +format. The `wasm_parity` raw `cdylib` example exports `parity_len` (digest words) +and `parity_word(index)` (four digest bytes interpreted big-endian). It has no JS +or host-import dependency. A runner can compare its words with the fixture hashes +on native and `wasm32-unknown-unknown`; an out-of-range index returns `u32::MAX`. +Building a WASM module alone is not evidence of executed native/WASM parity. diff --git a/crates/content-history/examples/wasm_parity.rs b/crates/content-history/examples/wasm_parity.rs new file mode 100644 index 000000000..23832ad79 --- /dev/null +++ b/crates/content-history/examples/wasm_parity.rs @@ -0,0 +1,13 @@ +//! Raw WASM parity fixture. No allocator pointers, JavaScript packages or host imports. +#[path = "../tests/common/mod.rs"] +mod common; + +#[unsafe(no_mangle)] +pub extern "C" fn parity_len() -> u32 { + 64 +} + +#[unsafe(no_mangle)] +pub extern "C" fn parity_word(index: u32) -> u32 { + common::parity_word(index) +} diff --git a/crates/content-history/src/lib.rs b/crates/content-history/src/lib.rs new file mode 100644 index 000000000..29b93b5d8 --- /dev/null +++ b/crates/content-history/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!("../README.md")] +#![forbid(unsafe_code)] + +mod model; +mod traversal; + +pub use model::{ + BlobId, ContentId, Entry, Error, History, HistoryId, Object, ObjectId, Tree, TreeId, verify, +}; +pub use traversal::{ + Change, ObjectReader, RestoredContent, RestoredEntry, Rewrite, TraversalLimits, diff, + replace_path, restore, +}; diff --git a/crates/content-history/src/model.rs b/crates/content-history/src/model.rs new file mode 100644 index 000000000..bba521707 --- /dev/null +++ b/crates/content-history/src/model.rs @@ -0,0 +1,337 @@ +use sha2::{Digest, Sha256}; +use std::{collections::BTreeSet, fmt}; + +macro_rules! identifier { + ($name:ident) => { + /// SHA-256 identity of canonical versioned bytes, not an access capability. + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 32]); + impl $name { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } + } + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({self})", stringify!($name)) + } + } + }; +} +identifier!(BlobId); +identifier!(TreeId); +identifier!(HistoryId); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ContentId { + Blob(BlobId), + Tree(TreeId), +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObjectId { + Blob(BlobId), + Tree(TreeId), + History(HistoryId), +} +impl From for ObjectId { + fn from(id: ContentId) -> Self { + match id { + ContentId::Blob(id) => Self::Blob(id), + ContentId::Tree(id) => Self::Tree(id), + } + } +} + +/// Failure is explicit: malformed or incomplete snapshots are never partial successes. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("invalid object magic")] + InvalidMagic, + #[error("unsupported format version {0}")] + UnsupportedVersion(u16), + #[error("invalid object tag {0}")] + InvalidTag(u8), + #[error("invalid child kind {0}")] + InvalidChildKind(u8), + #[error("invalid tree mode {0}")] + InvalidTreeMode(u8), + #[error("invalid parent flag {0}")] + InvalidParentFlag(u8), + #[error("truncated object")] + Truncated, + #[error("length does not fit remaining bytes or platform")] + InvalidLength, + #[error("invalid UTF-8")] + InvalidUtf8, + #[error("trailing bytes")] + TrailingBytes, + #[error("duplicate tree key {0:?}")] + DuplicateKey(String), + #[error("map entries are not in strict UTF-8 byte order")] + NonCanonicalMap, + #[error("object kind or hash does not match requested identity")] + HashMismatch, + #[error("missing object {0:?}")] + MissingObject(ObjectId), + #[error("reader failed: {0}")] + Read(String), + #[error("missing path key {0:?}")] + MissingKey(String), + #[error("path component is not a tree")] + NotTree, + #[error("replacement path must not be empty")] + EmptyPath, + #[error("traversal exceeds {0} limit")] + LimitExceeded(&'static str), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub key: String, + pub target: ContentId, +} + +/// Maps sort by exact UTF-8 bytes; sequences retain semantic order. Both have unique keys. +/// Empty strings, slashes and Unicode are opaque keys, not normalized virtual paths. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tree { + ordered: bool, + entries: Vec, +} +impl Tree { + pub fn map(mut entries: Vec) -> Result { + entries.sort_by(|a, b| a.key.cmp(&b.key)); + Self::build(entries, false) + } + pub fn sequence(entries: Vec) -> Result { + Self::build(entries, true) + } + fn build(entries: Vec, ordered: bool) -> Result { + let mut keys = BTreeSet::new(); + for entry in &entries { + if !keys.insert(&entry.key) { + return Err(Error::DuplicateKey(entry.key.clone())); + } + } + Ok(Self { ordered, entries }) + } + pub fn entries(&self) -> &[Entry] { + &self.entries + } + pub fn is_ordered(&self) -> bool { + self.ordered + } + /// Replace in place, append new sequence keys, or delete an existing key. + /// Deleting a missing key is an error; this never mutates the old tree. + pub fn replace(&self, key: &str, replacement: Option) -> Result { + let mut entries = self.entries.clone(); + match (entries.iter().position(|e| e.key == key), replacement) { + (Some(index), Some(target)) => entries[index].target = target, + (Some(index), None) => { + entries.remove(index); + } + (None, Some(target)) => entries.push(Entry { + key: key.into(), + target, + }), + (None, None) => return Err(Error::MissingKey(key.into())), + } + if self.ordered { + Self::sequence(entries) + } else { + Self::map(entries) + } + } +} + +/// Caller-authenticated editor/time facts. Restoring old content creates a new record +/// whose parent is the current head; this type does not implement adoption or head CAS. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct History { + pub root: TreeId, + pub parent: Option, + pub actor: String, + pub timestamp_micros: i64, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Object { + Blob(Vec), + Tree(Tree), + History(History), +} + +impl Object { + /// Canonical format v1. See the crate documentation for the compatibility boundary. + pub fn encode(&self) -> Vec { + let mut out = b"YCH\0\0\x01".to_vec(); + match self { + Self::Blob(bytes) => { + out.push(1); + put_bytes(&mut out, bytes); + } + Self::Tree(tree) => { + out.push(2); + out.push(u8::from(tree.ordered)); + put_u64(&mut out, tree.entries.len()); + for entry in &tree.entries { + put_bytes(&mut out, entry.key.as_bytes()); + match entry.target { + ContentId::Blob(id) => { + out.push(1); + out.extend_from_slice(id.as_bytes()); + } + ContentId::Tree(id) => { + out.push(2); + out.extend_from_slice(id.as_bytes()); + } + } + } + } + Self::History(history) => { + out.push(3); + out.extend_from_slice(history.root.as_bytes()); + match history.parent { + Some(id) => { + out.push(1); + out.extend_from_slice(id.as_bytes()); + } + None => out.push(0), + } + put_bytes(&mut out, history.actor.as_bytes()); + out.extend_from_slice(&history.timestamp_micros.to_be_bytes()); + } + } + out + } + /// Strict decoding rejects unknown versions, duplicate/unsorted maps and trailing bytes. + /// Lengths are checked against input before allocation; no input-driven capacity reservation. + pub fn decode(bytes: &[u8]) -> Result { + let mut input = Input(bytes); + if input.take(4)? != b"YCH\0" { + return Err(Error::InvalidMagic); + } + let version = u16::from_be_bytes(input.array()?); + if version != 1 { + return Err(Error::UnsupportedVersion(version)); + } + let object = match input.byte()? { + 1 => Self::Blob(input.bytes()?.to_vec()), + 2 => { + let ordered = match input.byte()? { + 0 => false, + 1 => true, + mode => return Err(Error::InvalidTreeMode(mode)), + }; + let count = input.length()?; + // Each entry needs a u64 key length, a kind and 32 digest bytes. + if count > input.0.len() / 41 { + return Err(Error::InvalidLength); + } + let mut entries: Vec = Vec::new(); + for _ in 0..count { + let key = input.string()?; + if !ordered && entries.last().is_some_and(|last| last.key >= key) { + return Err(Error::NonCanonicalMap); + } + let kind = input.byte()?; + let target = match kind { + 1 => ContentId::Blob(BlobId(input.array()?)), + 2 => ContentId::Tree(TreeId(input.array()?)), + _ => return Err(Error::InvalidChildKind(kind)), + }; + entries.push(Entry { key, target }); + } + Self::Tree(Tree::build(entries, ordered)?) + } + 3 => { + let root = TreeId(input.array()?); + let parent = match input.byte()? { + 0 => None, + 1 => Some(HistoryId(input.array()?)), + flag => return Err(Error::InvalidParentFlag(flag)), + }; + let actor = input.string()?; + let timestamp_micros = i64::from_be_bytes(input.array()?); + Self::History(History { + root, + parent, + actor, + timestamp_micros, + }) + } + tag => return Err(Error::InvalidTag(tag)), + }; + if !input.0.is_empty() { + return Err(Error::TrailingBytes); + } + Ok(object) + } + pub fn id(&self) -> ObjectId { + let hash: [u8; 32] = Sha256::digest(self.encode()).into(); + match self { + Self::Blob(_) => ObjectId::Blob(BlobId(hash)), + Self::Tree(_) => ObjectId::Tree(TreeId(hash)), + Self::History(_) => ObjectId::History(HistoryId(hash)), + } + } +} + +/// Validate exact stored bytes and requested kind before returning any content. +/// Decoding is intentionally first, so unsupported formats remain diagnosable. +pub fn verify(id: ObjectId, bytes: &[u8]) -> Result { + let object = Object::decode(bytes)?; + if object.id() != id { + return Err(Error::HashMismatch); + } + Ok(object) +} +fn put_u64(out: &mut Vec, length: usize) { + out.extend_from_slice(&(length as u64).to_be_bytes()); +} +fn put_bytes(out: &mut Vec, bytes: &[u8]) { + put_u64(out, bytes.len()); + out.extend_from_slice(bytes); +} +struct Input<'a>(&'a [u8]); +impl<'a> Input<'a> { + fn take(&mut self, count: usize) -> Result<&'a [u8], Error> { + if count > self.0.len() { + return Err(Error::Truncated); + } + let (head, tail) = self.0.split_at(count); + self.0 = tail; + Ok(head) + } + fn array(&mut self) -> Result<[u8; N], Error> { + Ok(self.take(N)?.try_into().expect("checked slice length")) + } + fn byte(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + fn length(&mut self) -> Result { + usize::try_from(u64::from_be_bytes(self.array()?)).map_err(|_| Error::InvalidLength) + } + fn bytes(&mut self) -> Result<&'a [u8], Error> { + let length = self.length()?; + if length > self.0.len() { + return Err(Error::InvalidLength); + } + self.take(length) + } + fn string(&mut self) -> Result { + std::str::from_utf8(self.bytes()?) + .map(str::to_owned) + .map_err(|_| Error::InvalidUtf8) + } +} diff --git a/crates/content-history/src/traversal.rs b/crates/content-history/src/traversal.rs new file mode 100644 index 000000000..0c1b5a6aa --- /dev/null +++ b/crates/content-history/src/traversal.rs @@ -0,0 +1,315 @@ +use crate::{ContentId, Error, Object, ObjectId, Tree, TreeId, verify}; +use std::collections::BTreeMap; + +/// Reader already bound to an authorized target and retention policy by its caller. +/// Hash possession is not authority. Revoked/erased objects must fail here even if +/// the physical bytes are deduplicated with another target. Missing data is an error. +/// The reader owns I/O size limits; traversal checks bytes after each returned buffer. +pub trait ObjectReader { + fn read(&mut self, id: ObjectId) -> Result, Error>; +} +impl Result, Error>> ObjectReader for F { + fn read(&mut self, id: ObjectId) -> Result, Error> { + self(id) + } +} + +/// Work budgets count all reads (including repeated shared subtrees) and encoded bytes. +/// Root depth is zero. Depth is additionally capped at 256 to bound Rust stack use. +#[derive(Clone, Copy, Debug)] +pub struct TraversalLimits { + pub max_depth: usize, + pub max_objects: usize, + pub max_bytes: usize, +} +impl Default for TraversalLimits { + fn default() -> Self { + Self { + max_depth: 128, + max_objects: 100_000, + max_bytes: 64 * 1024 * 1024, + } + } +} +struct Budget { + limits: TraversalLimits, + objects: usize, + bytes: usize, +} +impl Budget { + fn new(limits: TraversalLimits) -> Self { + Self { + limits, + objects: 0, + bytes: 0, + } + } + fn depth(&self, depth: usize) -> Result<(), Error> { + if depth > self.limits.max_depth.min(256) { + return Err(Error::LimitExceeded("depth")); + } + Ok(()) + } + fn read( + &mut self, + reader: &mut R, + id: ObjectId, + depth: usize, + ) -> Result { + self.depth(depth)?; + if self.objects >= self.limits.max_objects { + return Err(Error::LimitExceeded("objects")); + } + self.objects += 1; + let bytes = reader.read(id)?; + self.bytes = self + .bytes + .checked_add(bytes.len()) + .ok_or(Error::LimitExceeded("bytes"))?; + if self.bytes > self.limits.max_bytes { + return Err(Error::LimitExceeded("bytes")); + } + verify(id, &bytes) + } + fn tree( + &mut self, + reader: &mut R, + id: TreeId, + depth: usize, + ) -> Result { + match self.read(reader, ObjectId::Tree(id), depth)? { + Object::Tree(tree) => Ok(tree), + _ => Err(Error::NotTree), + } + } +} + +/// Fully materialized bytes and exact component order, not an applied business object. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RestoredContent { + Blob(Vec), + Tree { + ordered: bool, + entries: Vec, + }, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestoredEntry { + pub key: String, + pub content: RestoredContent, +} + +/// Restore a complete content snapshot without reading or replaying history/diffs. +/// On failure no partial result is returned. This does not restore state, assignments, +/// authorization, or other business events, nor adopt a historical head. +pub fn restore( + reader: &mut R, + root: TreeId, + limits: TraversalLimits, +) -> Result { + restore_content(reader, ContentId::Tree(root), &mut Budget::new(limits), 0) +} +fn restore_content( + reader: &mut R, + id: ContentId, + budget: &mut Budget, + depth: usize, +) -> Result { + match budget.read(reader, id.into(), depth)? { + Object::Blob(bytes) => Ok(RestoredContent::Blob(bytes)), + Object::Tree(tree) => { + let mut entries = Vec::new(); + for entry in tree.entries() { + entries.push(RestoredEntry { + key: entry.key.clone(), + content: restore_content(reader, entry.target, budget, depth + 1)?, + }); + } + Ok(RestoredContent::Tree { + ordered: tree.is_ordered(), + entries, + }) + } + Object::History(_) => unreachable!("verification enforces requested kind"), + } +} + +/// A replacement at an opaque key path. Empty path denotes the root itself. +/// Added/removed subtrees and tree mode/sequence layout changes are coarse replacements; +/// otherwise differences recurse by key. This is structural, not a text-line diff. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Change { + pub path: Vec, + pub before: Option, + pub after: Option, +} + +/// Skip identical subtree hashes without even reading them. This is not a closure +/// integrity check: unchanged or added/removed subtrees are intentionally not loaded. +/// Use [`restore`] when complete snapshot integrity is needed before adoption. +/// Order is deterministic: map keys sort by UTF-8; stable-layout sequences follow order. +pub fn diff( + reader: &mut R, + before: TreeId, + after: TreeId, + limits: TraversalLimits, +) -> Result, Error> { + let mut changes = Vec::new(); + diff_content( + reader, + ContentId::Tree(before), + ContentId::Tree(after), + &mut Vec::new(), + &mut Budget::new(limits), + &mut changes, + )?; + Ok(changes) +} +fn diff_content( + reader: &mut R, + before: ContentId, + after: ContentId, + path: &mut Vec, + budget: &mut Budget, + changes: &mut Vec, +) -> Result<(), Error> { + if before == after { + return Ok(()); + } + budget.depth(path.len())?; + if let (ContentId::Tree(left), ContentId::Tree(right)) = (before, after) { + let left = budget.tree(reader, left, path.len())?; + let right = budget.tree(reader, right, path.len())?; + let layout_changed = left.is_ordered() != right.is_ordered() + || (left.is_ordered() + && !left + .entries() + .iter() + .map(|e| &e.key) + .eq(right.entries().iter().map(|e| &e.key))); + if !layout_changed { + let left_by_key: BTreeMap<_, _> = left + .entries() + .iter() + .map(|e| (e.key.as_str(), e.target)) + .collect(); + let right_by_key: BTreeMap<_, _> = right + .entries() + .iter() + .map(|e| (e.key.as_str(), e.target)) + .collect(); + let keys: Vec<&str> = if left.is_ordered() { + left.entries().iter().map(|e| e.key.as_str()).collect() + } else { + left_by_key + .keys() + .chain(right_by_key.keys()) + .copied() + .collect::>() + .into_iter() + .collect() + }; + for key in keys { + path.push(key.into()); + let old = left_by_key.get(key).copied(); + let new = right_by_key.get(key).copied(); + match (old, new) { + (Some(old), Some(new)) => { + diff_content(reader, old, new, path, budget, changes)? + } + _ => changes.push(Change { + path: path.clone(), + before: old, + after: new, + }), + } + path.pop(); + } + return Ok(()); + } + } + changes.push(Change { + path: path.clone(), + before: Some(before), + after: Some(after), + }); + Ok(()) +} + +/// New ancestor objects, leaf-first. The replacement object itself is caller-owned. +/// Nothing is saved or adopted; caller must persist a complete verified closure and +/// history before atomically CAS-adopting the new head in its transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rewrite { + pub root: TreeId, + pub objects: Vec, +} + +/// Copy-on-write replacement along exact keys (no slash splitting/normalization). +/// Intermediate keys must exist and refer to trees. The final key may be inserted, +/// replaced, or deleted. Sequence insertion appends; replacement preserves position. +/// Only ancestors are read; this does not validate the replacement object's closure. +pub fn replace_path( + reader: &mut R, + root: TreeId, + path: &[&str], + replacement: Option, + limits: TraversalLimits, +) -> Result { + if path.is_empty() { + return Err(Error::EmptyPath); + } + let mut budget = Budget::new(limits); + budget.depth(path.len())?; + let mut objects = Vec::new(); + let root = rewrite( + reader, + root, + path, + replacement, + &mut budget, + 0, + &mut objects, + )?; + Ok(Rewrite { root, objects }) +} +fn rewrite( + reader: &mut R, + root: TreeId, + path: &[&str], + replacement: Option, + budget: &mut Budget, + depth: usize, + objects: &mut Vec, +) -> Result { + let tree = budget.tree(reader, root, depth)?; + let target = if path.len() == 1 { + replacement + } else { + let entry = tree + .entries() + .iter() + .find(|e| e.key == path[0]) + .ok_or_else(|| Error::MissingKey(path[0].into()))?; + let ContentId::Tree(child) = entry.target else { + return Err(Error::NotTree); + }; + Some(ContentId::Tree(rewrite( + reader, + child, + &path[1..], + replacement, + budget, + depth + 1, + objects, + )?)) + }; + let object = Object::Tree(tree.replace(path[0], target)?); + let ObjectId::Tree(id) = object.id() else { + unreachable!("tree object") + }; + if id != root { + objects.push(object); + } + Ok(id) +} From 2998f53de76a6e44e911cf9ba3624ae22c2ae7bc Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 10:50:47 +0900 Subject: [PATCH 2/7] Establish payload-free permission contracts and coordinated dependency checkpoint T836 declaration/resolver leaf; no policy adoption or real-operation authorization. Includes T843 confirmed error-contract dependency edges and T835 Server test dependency edges from exact 29cb24a878fb2f7183b022da6d4316e137edd946. T835 foundation source 223aa125de44728ddcb553ec45a732eef9d72bab was adopted separately. External lock pins unchanged. Initial resolver12/wire8 passed before catalog snapshot freshness refinement; post-refinement Rust validation pending build-slot handoff. --- Cargo.lock | 20 + Cargo.toml | 3 + crates/feature-permissions/Cargo.toml | 16 + crates/feature-permissions/README.md | 60 ++ crates/feature-permissions/src/lib.rs | 10 + crates/feature-permissions/src/registry.rs | 668 +++++++++++++++++++++ crates/feature-permissions/src/types.rs | 218 +++++++ crates/server-api/Cargo.toml | 1 + crates/session-store/Cargo.toml | 1 + crates/worker-runtime/Cargo.toml | 1 + crates/worker/Cargo.toml | 2 + crates/workspace-server/Cargo.toml | 3 + 12 files changed, 1003 insertions(+) create mode 100644 crates/feature-permissions/Cargo.toml create mode 100644 crates/feature-permissions/README.md create mode 100644 crates/feature-permissions/src/lib.rs create mode 100644 crates/feature-permissions/src/registry.rs create mode 100644 crates/feature-permissions/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index b5edee248..ffec5d235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "feature-permissions" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "ts-rs", +] + [[package]] name = "feature-storage" version = "0.1.0" @@ -4752,6 +4764,7 @@ name = "server-api" version = "0.1.0" dependencies = [ "api-macros", + "feature-permissions", "memory", "merge-request", "plugin-plan", @@ -4797,6 +4810,7 @@ dependencies = [ "agen", "async-trait", "base64 0.22.1", + "error-contract", "filetime", "fs4", "futures", @@ -7301,6 +7315,8 @@ dependencies = [ "client", "config-source", "dotenv", + "error-contract", + "feature-permissions", "flow", "fs-operation", "fs4", @@ -7357,6 +7373,7 @@ dependencies = [ "axum", "base64 0.22.1", "decodal", + "error-contract", "flow", "fs4", "futures", @@ -7559,6 +7576,9 @@ dependencies = [ "axum", "chrono", "config-source", + "content-history", + "content-history-adapters", + "content-history-store", "feature-storage", "flate2", "flow", diff --git a/Cargo.toml b/Cargo.toml index 6fa60d16a..29515ffd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/tui", "crates/memory", "crates/feature-storage", + "crates/feature-permissions", "crates/workspace-drive", "crates/subjektiv", "crates/ticket", @@ -84,6 +85,7 @@ default-members = [ "crates/tui", "crates/memory", "crates/feature-storage", + "crates/feature-permissions", "crates/workspace-drive", "crates/subjektiv", "crates/ticket", @@ -124,6 +126,7 @@ mcp = { path = "crates/mcp" } lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } feature-storage = { path = "crates/feature-storage" } +feature-permissions = { path = "crates/feature-permissions" } workspace-drive = { path = "crates/workspace-drive" } subjektiv = { path = "crates/subjektiv" } merge-request = { path = "crates/merge-request" } diff --git a/crates/feature-permissions/Cargo.toml b/crates/feature-permissions/Cargo.toml new file mode 100644 index 000000000..c00c08e8d --- /dev/null +++ b/crates/feature-permissions/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "feature-permissions" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +schemars.workspace = true +serde = { workspace = true, features = ["derive"] } +thiserror.workspace = true +ts-rs = "12.0.1" + +[dev-dependencies] +serde_json.workspace = true +toml.workspace = true diff --git a/crates/feature-permissions/README.md b/crates/feature-permissions/README.md new file mode 100644 index 000000000..31b3fadd3 --- /dev/null +++ b/crates/feature-permissions/README.md @@ -0,0 +1,60 @@ +# Feature permissions + +This crate owns payload-free declarations, assignment DTOs, catalog projections, +explicit children resolution and operation-key delegation limits. It does not +adopt policy, migrate old grants, authorize a real operation, or choose Worker +permissions. See [`PermissionHost`] for the trusted registration boundary and +[`types`] for the shared text/storage/management/Web representation. + +The Host authenticates a Workspace and a builtin or trusted Plugin identity +before registering it. It retains [`FeatureHandle`] and supplies only the bound +[`NamespaceHandle`] to that Feature's declaration/query adapter. Guest input is +[`NamespaceDeclaration`], never an owner string or a handle. Constructing another +Host with the same textual Workspace identity does not create interchangeable +handles or results. A namespace conveys ownership, not permission to execute. + +Children are graph edges, not dotted-prefix wildcards. A key can have multiple +segments and explicit children; a shared set collects Feature contributions using +the same references. Installing a declaration validates its candidate graph +atomically. Duplicate definitions, unknown references and cycles reject the update +without changing the prior catalog. Duplicate assignment paths are deduplicated, +with distinct provenance paths retained in deterministic order. Invalid assignment +references deny the whole assignment with diagnostics. Disabled contributions are +omitted independently; removing a Feature retires its handles and withdraws only +its declarations. Dangling references deny rather than recovering an old result. + +Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the +current assignment before use. A resolution retains its actual catalog snapshot; +query rejects it unless that snapshot is still the Host's current catalog. There +is no independently ordered freshness counter. Unset, +explicit empty, exclusion and revocation never select a default. Exclusions expand +through the same children graph, then win over every grant path. No snapshot is a +policy store: consumers must read current policy and authenticated subject/Job +context at their actual operation boundary. + +A [`LimitedGrant`] freezes the expanded keys actually passed at issuance. Later +set or children additions can extend a set-holding parent, not that grant. Child +execution intersects the fresh parent execution, fresh parent delegation ceiling +and frozen grant. A [`JobGrant`] is one further intersection bound to the Host's +Job identity; it never substitutes for Subject policy, attempt/writer/input/expiry +checks. Revoked grant objects cannot be made active again. Operation-key checks do +not replace provider path, recursive, symlink, network or domain constraints. + +A text/storage/management assignment uses an explicit discriminant, for example: + +```json +{ + "grants": [ + {"kind":"set", "value":"companion"}, + {"kind":"namespace", "value":{"namespace":"ticket", "keys":["ticket.hoge.fuga"]}} + ], + "exclusions": [], + "revoked": false +} +``` + +The same Serde types validate dynamic inputs; unknown fields (including payloads) +are rejected. [`typescript`] generates the Web contract. Management projections are +serializable observations, not credentials: deserialization cannot manufacture a +runtime resolution, namespace handle or delegation grant. The consumer owns +policy persistence and its route/subject authentication, not this DTO layer. diff --git a/crates/feature-permissions/src/lib.rs b/crates/feature-permissions/src/lib.rs new file mode 100644 index 000000000..6b8732c79 --- /dev/null +++ b/crates/feature-permissions/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!("../README.md")] + +pub mod types; +pub use types::*; + +mod registry; +pub use registry::{ + FeatureHandle, JobGrant, LimitedGrant, NamespaceHandle, PermissionError, PermissionHost, + ResolvedPermissions, +}; diff --git a/crates/feature-permissions/src/registry.rs b/crates/feature-permissions/src/registry.rs new file mode 100644 index 000000000..f00012637 --- /dev/null +++ b/crates/feature-permissions/src/registry.rs @@ -0,0 +1,668 @@ +//! Registration is a trusted Host operation. Wire declarations cannot construct +//! handles. A handle is bound to one registry instance and one registration, not +//! merely to matching Workspace/Feature strings. Reinstallation retires old handles. + +use crate::types::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PermissionError { + #[error("handle belongs to another Host or a retired registration")] + ForeignHandle, + #[error("Feature already registered: {0}")] + FeatureCollision(FeatureIdentity), + #[error("namespace already owned: {0}")] + NamespaceCollision(NamespaceId), + #[error("declaration does not belong to namespace: {0}")] + Ownership(PermissionKey), + #[error("duplicate declaration: {0}")] + Duplicate(String), + #[error("unknown key: {0}")] + UnknownKey(PermissionKey), + #[error("unknown set: {0}")] + UnknownSet(PermissionSetId), + #[error("cycle in permission graph: {0}")] + Cycle(String), + #[error("resolution is stale; resolve current policy again")] + StaleResolution, + #[error("requested delegation exceeds current ceiling: {0}")] + DelegationExceeded(PermissionKey), + #[error("limited grant has been revoked")] + RevokedGrant, + #[error("Job grant belongs to another Job")] + WrongJob, +} + +#[derive(Clone, Debug)] +pub struct FeatureHandle { + host: Arc<()>, + registration: Arc<()>, + identity: FeatureIdentity, +} +#[derive(Clone, Debug)] +pub struct NamespaceHandle { + feature: FeatureHandle, + namespace: NamespaceId, +} +impl NamespaceHandle { + pub fn namespace(&self) -> &NamespaceId { + &self.namespace + } +} +#[derive(Clone, Debug)] +struct FeatureRecord { + registration: Arc<()>, + enabled: bool, +} +#[derive(Clone, Debug)] +struct NamespaceRecord { + owner: FeatureIdentity, + declaration: NamespaceDeclaration, +} + +/// Not deserializable: a management projection cannot become an authorization +/// result. Results must be re-resolved after *any* registry change. +#[derive(Debug)] +pub struct ResolvedPermissions { + host: Arc<()>, + catalog: Arc, + projection: PermissionResolution, +} +impl ResolvedPermissions { + pub fn projection(&self) -> &PermissionResolution { + &self.projection + } +} + +/// Keys are expanded once at issuance and never expanded again. Thus later +/// children/set additions cannot enlarge an already issued child grant. +#[derive(Debug)] +pub struct LimitedGrant { + host: Arc<()>, + keys: BTreeSet, + registrations: BTreeMap>, + revoked: bool, +} +impl LimitedGrant { + pub fn keys(&self) -> &BTreeSet { + &self.keys + } + pub fn revoke(&mut self) { + self.revoked = true; + } +} + +/// A Job's operation grant is an additional limit, not an alternative policy. +/// The caller supplies the authenticated Job identity at issuance and use; this +/// does not validate attempts, writers, deadlines or provider restrictions. +#[derive(Debug)] +pub struct JobGrant { + job: String, + limit: LimitedGrant, +} +impl JobGrant { + pub fn revoke(&mut self) { + self.limit.revoke(); + } +} + +pub struct PermissionHost { + identity: Arc<()>, + workspace: WorkspaceId, + catalog: Arc, +} + +#[derive(Clone, Debug, Default)] +struct RegistryState { + features: BTreeMap, + namespaces: BTreeMap, +} + +impl PermissionHost { + /// Constructed by the trusted Workspace Host, not from a Feature's input. + pub fn new(workspace: WorkspaceId) -> Self { + Self { + identity: Arc::new(()), + workspace, + catalog: Arc::new(RegistryState::default()), + } + } + + /// Only the trusted loader calls this after establishing the actual builtin + /// or trusted Plugin identity. There is no Feature-facing raw-owner lookup. + pub fn register_feature( + &mut self, + identity: FeatureIdentity, + ) -> Result { + if self.catalog.features.contains_key(&identity) { + return Err(PermissionError::FeatureCollision(identity)); + } + let registration = Arc::new(()); + Arc::make_mut(&mut self.catalog).features.insert( + identity.clone(), + FeatureRecord { + registration: registration.clone(), + enabled: true, + }, + ); + Ok(FeatureHandle { + host: self.identity.clone(), + registration, + identity, + }) + } + + fn check_feature(&self, feature: &FeatureHandle) -> Result<(), PermissionError> { + if !Arc::ptr_eq(&self.identity, &feature.host) + || !self + .catalog + .features + .get(&feature.identity) + .is_some_and(|r| Arc::ptr_eq(&r.registration, &feature.registration)) + { + return Err(PermissionError::ForeignHandle); + } + Ok(()) + } + fn check_namespace(&self, handle: &NamespaceHandle) -> Result<(), PermissionError> { + self.check_feature(&handle.feature)?; + if !self + .catalog + .namespaces + .get(&handle.namespace) + .is_some_and(|r| r.owner == handle.feature.identity) + { + return Err(PermissionError::ForeignHandle); + } + Ok(()) + } + + pub fn bind_namespace( + &mut self, + feature: &FeatureHandle, + namespace: NamespaceId, + ) -> Result { + self.check_feature(feature)?; + if self.catalog.namespaces.contains_key(&namespace) { + return Err(PermissionError::NamespaceCollision(namespace)); + } + Arc::make_mut(&mut self.catalog).namespaces.insert( + namespace.clone(), + NamespaceRecord { + owner: feature.identity.clone(), + declaration: NamespaceDeclaration { + permissions: vec![], + sets: vec![], + }, + }, + ); + Ok(NamespaceHandle { + feature: feature.clone(), + namespace, + }) + } + + /// Replace atomically. All keys and set contributions must belong to this + /// namespace. References to existing other namespaces do not transfer ownership. + pub fn declare( + &mut self, + handle: &NamespaceHandle, + declaration: NamespaceDeclaration, + ) -> Result<(), PermissionError> { + self.check_namespace(handle)?; + let mut keys = BTreeSet::new(); + for definition in &declaration.permissions { + if definition.key.namespace() != handle.namespace { + return Err(PermissionError::Ownership(definition.key.clone())); + } + if !keys.insert(definition.key.clone()) { + return Err(PermissionError::Duplicate(definition.key.to_string())); + } + let mut children = BTreeSet::new(); + for child in &definition.children { + if !children.insert(child) { + return Err(PermissionError::Duplicate(child.to_string())); + } + } + } + let mut sets = BTreeSet::new(); + for contribution in &declaration.sets { + if !sets.insert(&contribution.set) { + return Err(PermissionError::Duplicate(contribution.set.to_string())); + } + for reference in &contribution.children { + if let PermissionReference::Namespace(ns) = reference { + if ns.namespace != handle.namespace { + return Err(PermissionError::ForeignHandle); + } + for key in &ns.keys { + if key.namespace() != ns.namespace { + return Err(PermissionError::Ownership(key.clone())); + } + } + } + } + } + let mut candidate = self.catalog.namespaces.clone(); + candidate + .get_mut(&handle.namespace) + .expect("checked namespace") + .declaration = declaration; + validate_graph(&candidate)?; + Arc::make_mut(&mut self.catalog).namespaces = candidate; + Ok(()) + } + + pub fn set_enabled( + &mut self, + feature: &FeatureHandle, + enabled: bool, + ) -> Result<(), PermissionError> { + self.check_feature(feature)?; + if self.catalog.features[&feature.identity].enabled != enabled { + Arc::make_mut(&mut self.catalog) + .features + .get_mut(&feature.identity) + .expect("checked feature") + .enabled = enabled; + } + Ok(()) + } + + /// Removal withdraws only this Feature's definitions/contributions. Dangling + /// references remain diagnostic and fail closed at resolution, never cached. + pub fn remove_feature(&mut self, feature: &FeatureHandle) -> Result<(), PermissionError> { + self.check_feature(feature)?; + Arc::make_mut(&mut self.catalog) + .features + .remove(&feature.identity); + Arc::make_mut(&mut self.catalog) + .namespaces + .retain(|_, r| r.owner != feature.identity); + Ok(()) + } + + pub fn catalog(&self) -> PermissionCatalog { + PermissionCatalog { + workspace: self.workspace.clone(), + namespaces: self + .catalog + .namespaces + .iter() + .map(|(ns, r)| NamespaceCatalog { + namespace: ns.clone(), + feature: r.owner.clone(), + enabled: self.catalog.features[&r.owner].enabled, + declaration: r.declaration.clone(), + }) + .collect(), + } + } + + pub fn resolve(&self, assignment: Option<&PermissionAssignment>) -> ResolvedPermissions { + let mut projection = PermissionResolution { + workspace: self.workspace.clone(), + allowed: BTreeMap::new(), + diagnostics: vec![], + }; + match assignment { + None => projection.diagnostics.push(PermissionDiagnostic::Unset), + Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked), + Some(a) => { + let mut expansion = Expansion::new(self); + for reference in &a.grants { + expansion.reference(reference, vec![]); + } + let mut exclusions = Expansion::new(self); + for reference in &a.exclusions { + exclusions.reference(reference, vec![]); + } + for key in exclusions.allowed.keys() { + if expansion.allowed.remove(key).is_some() { + expansion + .diagnostics + .insert(PermissionDiagnostic::Excluded(key.clone())); + } + } + expansion.diagnostics.extend(exclusions.diagnostics); + // Invalid references, including malformed namespace/key pairings, + // invalidate the assignment instead of partially accepting a typo. + if !expansion.diagnostics.iter().any(|d| { + matches!( + d, + PermissionDiagnostic::UnknownKey(_) + | PermissionDiagnostic::UnknownSet(_) + | PermissionDiagnostic::UnknownNamespace(_) + ) + }) { + projection.allowed = expansion + .allowed + .into_iter() + .map(|(key, origins)| (key, origins.into_iter().collect())) + .collect(); + } + projection.diagnostics = expansion.diagnostics.into_iter().collect(); + } + } + ResolvedPermissions { + host: self.identity.clone(), + catalog: self.catalog.clone(), + projection, + } + } + + fn check_resolution(&self, resolved: &ResolvedPermissions) -> Result<(), PermissionError> { + if !Arc::ptr_eq(&self.identity, &resolved.host) { + return Err(PermissionError::ForeignHandle); + } + if !Arc::ptr_eq(&self.catalog, &resolved.catalog) { + return Err(PermissionError::StaleResolution); + } + Ok(()) + } + + /// A key permission is only one conjunct. The execution boundary must still + /// check path/provider/context/domain restrictions at the actual operation. + pub fn allows( + &self, + namespace: &NamespaceHandle, + resolved: &ResolvedPermissions, + key: &PermissionKey, + ) -> Result { + self.check_namespace(namespace)?; + self.check_resolution(resolved)?; + if key.namespace() != namespace.namespace { + return Err(PermissionError::Ownership(key.clone())); + } + Ok(resolved.projection.allowed.contains_key(key)) + } + + pub fn issue_delegation( + &self, + ceiling: &ResolvedPermissions, + requested: &[PermissionKey], + ) -> Result { + self.check_resolution(ceiling)?; + let mut expansion = Expansion::new(self); + for key in requested { + expansion.key(key, vec![]); + } + for diagnostic in &expansion.diagnostics { + if let PermissionDiagnostic::UnknownKey(key) = diagnostic { + return Err(PermissionError::UnknownKey(key.clone())); + } + } + for key in expansion.allowed.keys() { + if !ceiling.projection.allowed.contains_key(key) { + return Err(PermissionError::DelegationExceeded(key.clone())); + } + } + Ok(LimitedGrant { + host: self.identity.clone(), + registrations: expansion + .allowed + .keys() + .map(|key| { + let owner = &self.catalog.namespaces[&key.namespace()].owner; + ( + key.clone(), + self.catalog.features[owner].registration.clone(), + ) + }) + .collect(), + keys: expansion.allowed.into_keys().collect(), + revoked: false, + }) + } + + pub fn issue_job_grant( + &self, + job: String, + ceiling: &ResolvedPermissions, + requested: &[PermissionKey], + ) -> Result { + Ok(JobGrant { + job, + limit: self.issue_delegation(ceiling, requested)?, + }) + } + + fn grant_key_is_current(&self, grant: &LimitedGrant, key: &PermissionKey) -> bool { + let Some(record) = self.catalog.namespaces.get(&key.namespace()) else { + return false; + }; + grant.registrations.get(key).is_some_and(|registration| { + let feature = &self.catalog.features[&record.owner]; + feature.enabled && Arc::ptr_eq(registration, &feature.registration) + }) + } + + fn check_grant(&self, grant: &LimitedGrant) -> Result<(), PermissionError> { + if !Arc::ptr_eq(&self.identity, &grant.host) { + return Err(PermissionError::ForeignHandle); + } + if grant.revoked { + return Err(PermissionError::RevokedGrant); + } + Ok(()) + } + + /// Parent execution, parent delegation and actually passed keys all constrain + /// the child. This exact-set intersection performs no children expansion. + pub fn resolve_limited( + &self, + execution: &ResolvedPermissions, + delegation: &ResolvedPermissions, + grant: &LimitedGrant, + ) -> Result { + self.check_resolution(execution)?; + self.check_resolution(delegation)?; + self.check_grant(grant)?; + let mut projection = execution.projection.clone(); + projection.allowed.retain(|key, _| { + grant.keys.contains(key) + && self.grant_key_is_current(grant, key) + && delegation.projection.allowed.contains_key(key) + }); + Ok(ResolvedPermissions { + host: self.identity.clone(), + catalog: self.catalog.clone(), + projection, + }) + } + + pub fn resolve_job( + &self, + job: &str, + execution: &ResolvedPermissions, + delegation: &ResolvedPermissions, + child: &LimitedGrant, + grant: &JobGrant, + ) -> Result { + if grant.job != job { + return Err(PermissionError::WrongJob); + } + self.check_grant(&grant.limit)?; + let mut result = self.resolve_limited(execution, delegation, child)?; + result.projection.allowed.retain(|key, _| { + grant.limit.keys.contains(key) && self.grant_key_is_current(&grant.limit, key) + }); + Ok(result) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Node { + Key(PermissionKey), + Set(PermissionSetId), +} + +fn graph( + namespaces: &BTreeMap, +) -> Result>, PermissionError> { + let mut nodes = BTreeMap::new(); + for r in namespaces.values() { + for d in &r.declaration.permissions { + nodes.insert( + Node::Key(d.key.clone()), + d.children.iter().cloned().map(Node::Key).collect(), + ); + } + for s in &r.declaration.sets { + let edges = nodes + .entry(Node::Set(s.set.clone())) + .or_insert_with(Vec::new); + for reference in &s.children { + match reference { + PermissionReference::Set(set) => edges.push(Node::Set(set.clone())), + PermissionReference::Namespace(ns) => { + for key in &ns.keys { + if key.namespace() != ns.namespace { + return Err(PermissionError::Ownership(key.clone())); + } + edges.push(Node::Key(key.clone())); + } + } + } + } + } + } + Ok(nodes) +} + +fn validate_graph( + namespaces: &BTreeMap, +) -> Result<(), PermissionError> { + fn visit( + node: &Node, + nodes: &BTreeMap>, + active: &mut BTreeSet, + done: &mut BTreeSet, + ) -> Result<(), PermissionError> { + if done.contains(node) { + return Ok(()); + } + if !active.insert(node.clone()) { + return Err(PermissionError::Cycle(format!("{node:?}"))); + } + let edges = nodes.get(node).ok_or_else(|| match node { + Node::Key(k) => PermissionError::UnknownKey(k.clone()), + Node::Set(s) => PermissionError::UnknownSet(s.clone()), + })?; + for child in edges { + visit(child, nodes, active, done)?; + } + active.remove(node); + done.insert(node.clone()); + Ok(()) + } + let nodes = graph(namespaces)?; + let mut done = BTreeSet::new(); + for node in nodes.keys() { + visit(node, &nodes, &mut BTreeSet::new(), &mut done)?; + } + Ok(()) +} + +struct Expansion<'a> { + host: &'a PermissionHost, + allowed: BTreeMap>>, + diagnostics: BTreeSet, +} +impl<'a> Expansion<'a> { + fn new(host: &'a PermissionHost) -> Self { + Self { + host, + allowed: BTreeMap::new(), + diagnostics: BTreeSet::new(), + } + } + fn reference(&mut self, reference: &PermissionReference, path: Vec) { + match reference { + PermissionReference::Namespace(ns) => { + if !self.host.catalog.namespaces.contains_key(&ns.namespace) { + self.diagnostics + .insert(PermissionDiagnostic::UnknownNamespace(ns.namespace.clone())); + return; + } + for key in &ns.keys { + if key.namespace() != ns.namespace { + self.diagnostics + .insert(PermissionDiagnostic::UnknownKey(key.clone())); + } else { + self.key(key, path.clone()); + } + } + } + PermissionReference::Set(set) => { + // Cycles cannot be installed; the path guard also makes this + // traversal robust to future graph-loading implementations. + if path.contains(&OriginStep::Set(set.clone())) { + return; + } + let mut found = false; + for record in self.host.catalog.namespaces.values() { + for contribution in &record.declaration.sets { + if &contribution.set != set { + continue; + } + found = true; + if !self.host.catalog.features[&record.owner].enabled { + self.diagnostics + .insert(PermissionDiagnostic::DisabledFeature( + record.owner.clone(), + )); + continue; + } + let mut next = path.clone(); + next.push(OriginStep::Set(set.clone())); + next.push(OriginStep::Feature(record.owner.clone())); + for child in &contribution.children { + self.reference(child, next.clone()); + } + } + } + if !found { + self.diagnostics + .insert(PermissionDiagnostic::UnknownSet(set.clone())); + } + } + } + } + fn key(&mut self, key: &PermissionKey, mut path: Vec) { + let Some(record) = self.host.catalog.namespaces.get(&key.namespace()) else { + self.diagnostics + .insert(PermissionDiagnostic::UnknownKey(key.clone())); + return; + }; + let Some(definition) = record + .declaration + .permissions + .iter() + .find(|d| &d.key == key) + else { + self.diagnostics + .insert(PermissionDiagnostic::UnknownKey(key.clone())); + return; + }; + if !self.host.catalog.features[&record.owner].enabled { + self.diagnostics + .insert(PermissionDiagnostic::DisabledFeature(record.owner.clone())); + return; + } + if path.contains(&OriginStep::Key(key.clone())) { + return; + } + path.push(OriginStep::Feature(record.owner.clone())); + path.push(OriginStep::Key(key.clone())); + self.allowed + .entry(key.clone()) + .or_default() + .insert(path.clone()); + for child in &definition.children { + self.key(child, path.clone()); + } + } +} diff --git a/crates/feature-permissions/src/types.rs b/crates/feature-permissions/src/types.rs new file mode 100644 index 000000000..35ea929ac --- /dev/null +++ b/crates/feature-permissions/src/types.rs @@ -0,0 +1,218 @@ +//! Wire values describe grants, never authority-bearing handles. Unknown fields +//! are rejected so old payload-bearing inputs cannot silently broaden a grant. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use ts_rs::TS; + +macro_rules! identifier { + ($name:ident, $validate:expr) => { + #[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, TS, schemars::JsonSchema, + )] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if ($validate)(&value) { + Ok(Self(value)) + } else { + Err(format!("invalid {}: {value:?}", stringify!($name))) + } + } + pub fn as_str(&self) -> &str { + &self.0 + } + } + impl<'de> Deserialize<'de> for $name { + fn deserialize>(d: D) -> Result { + Self::parse(String::deserialize(d)?).map_err(serde::de::Error::custom) + } + } + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +fn segment(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_lowercase() + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') +} +fn identity(value: &str) -> bool { + !value.is_empty() && !value.chars().any(|c| c.is_control() || c.is_whitespace()) +} +identifier!(WorkspaceId, identity); +identifier!(FeatureIdentity, identity); +identifier!(PermissionSetId, segment); +identifier!(NamespaceId, segment); +identifier!(PermissionKey, |value: &str| value.split('.').count() >= 2 + && value.split('.').all(segment)); + +impl PermissionKey { + pub fn namespace(&self) -> NamespaceId { + NamespaceId(self.0.split('.').next().expect("validated key").to_owned()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NamespacePermissions { + pub namespace: NamespaceId, + pub keys: Vec, +} + +/// The discriminant, not an identifier's spelling, selects a reference kind. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum PermissionReference { + Set(PermissionSetId), + Namespace(NamespacePermissions), +} + +/// `None` at the consuming policy boundary means unset. An empty assignment is +/// explicit deny-all; this crate never chooses or reinstates a default. +#[derive( + Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct PermissionAssignment { + pub grants: Vec, + pub exclusions: Vec, + pub revoked: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PermissionDefinition { + pub key: PermissionKey, + pub description: String, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SetContribution { + pub set: PermissionSetId, + pub children: Vec, +} + +/// This input deliberately has no owner field. The Host supplies the registered +/// identity/namespace handle independently of guest-controlled declaration data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NamespaceDeclaration { + pub permissions: Vec, + pub sets: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NamespaceCatalog { + pub namespace: NamespaceId, + pub feature: FeatureIdentity, + pub enabled: bool, + pub declaration: NamespaceDeclaration, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PermissionCatalog { + pub workspace: WorkspaceId, + pub namespaces: Vec, +} + +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema, +)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum OriginStep { + Set(PermissionSetId), + Feature(FeatureIdentity), + Key(PermissionKey), +} + +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS, schemars::JsonSchema, +)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum PermissionDiagnostic { + Unset, + Revoked, + UnknownSet(PermissionSetId), + UnknownNamespace(NamespaceId), + UnknownKey(PermissionKey), + DisabledFeature(FeatureIdentity), + Excluded(PermissionKey), +} + +/// A management projection, not an executable credential. Only the Host's opaque +/// resolution can be queried through a namespace handle. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PermissionResolution { + pub workspace: WorkspaceId, + pub allowed: BTreeMap>>, + pub diagnostics: Vec, +} + +/// The management boundary uses exactly the same assignment as text/storage. +/// Workspace comes from authenticated routing, not a repeated permission field. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ValidatePermissionsRequest { + pub assignment: PermissionAssignment, +} + +pub fn typescript() -> String { + let config = ts_rs::Config::default(); + let declarations = [ + WorkspaceId::decl(&config), + FeatureIdentity::decl(&config), + PermissionSetId::decl(&config), + NamespaceId::decl(&config), + PermissionKey::decl(&config), + NamespacePermissions::decl(&config), + PermissionReference::decl(&config), + PermissionAssignment::decl(&config), + PermissionDefinition::decl(&config), + SetContribution::decl(&config), + NamespaceDeclaration::decl(&config), + NamespaceCatalog::decl(&config), + PermissionCatalog::decl(&config), + OriginStep::decl(&config), + PermissionDiagnostic::decl(&config), + PermissionResolution::decl(&config), + ValidatePermissionsRequest::decl(&config), + ]; + format!( + "// Generated by feature-permissions; do not edit.\n{}\n", + declarations + .iter() + .map(|d| format!("export {d}")) + .collect::>() + .join("\n") + ) +} diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 2211663bc..5af04009e 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -10,6 +10,7 @@ default = [] typescript = ["dep:ts-rs", "protocol/typescript"] [dependencies] +feature-permissions.workspace = true api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] } memory.workspace = true plugin-plan.workspace = true diff --git a/crates/session-store/Cargo.toml b/crates/session-store/Cargo.toml index cc697cef3..d86c6f6a9 100644 --- a/crates/session-store/Cargo.toml +++ b/crates/session-store/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true [dependencies] +error-contract.workspace = true base64.workspace = true agen = { workspace = true } fs4.workspace = true diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index d6fe83605..4ab827331 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -27,6 +27,7 @@ http-server = ["dep:axum", "dep:tower", "dep:reqwest"] ws-server = ["http-server", "axum/ws", "tokio/sync"] [dependencies] +error-contract.workspace = true agen.workspace = true async-trait.workspace = true base64.workspace = true diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 139758d44..5543317ff 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -10,6 +10,8 @@ autobins = false default = [] [dependencies] +error-contract.workspace = true +feature-permissions.workspace = true async-trait = { workspace = true } agen = { workspace = true, features = ["codex"] } session-store = { workspace = true } diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index fbabe2908..faec89491 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -66,6 +66,9 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] } webauthn-rs-proto = "0.5.5" [dev-dependencies] +content-history.workspace = true +content-history-store.workspace = true +content-history-adapters.workspace = true feature-storage = { workspace = true, features = ["test-support"] } native-tls = "0.2" tokio-native-tls = "0.3" From 3078d0cee9b3651ae853a3be659860d52d2211b9 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 11:03:26 +0900 Subject: [PATCH 3/7] chore: format the pinned content history foundation Only rustfmt normalization of exact T835 dependency snapshot 223aa125de44728ddcb553ec45a732eef9d72bab. Diff reviewed: whitespace and optional trailing-comma changes only; string literal sequences unchanged. No semantic fixes, manifests or lock changes adopted from the in-progress branch. --- .../content-history-adapters/src/settings.rs | 5 +- crates/content-history-store/src/lib.rs | 128 +++++++++++++----- 2 files changed, 98 insertions(+), 35 deletions(-) diff --git a/crates/content-history-adapters/src/settings.rs b/crates/content-history-adapters/src/settings.rs index bd0cea662..d934b8785 100644 --- a/crates/content-history-adapters/src/settings.rs +++ b/crates/content-history-adapters/src/settings.rs @@ -174,7 +174,10 @@ fn restore_directory( // A valid directory must lead to an as-yet unrestored metadata entry. This also // bounds traversal of adversarial shared subtrees by the existing entry/path limits. let child_prefix = format!("{path}/"); - if !metadata.keys().any(|key| key.as_str().starts_with(&child_prefix)) { + if !metadata + .keys() + .any(|key| key.as_str().starts_with(&child_prefix)) + { return Err(Error::Structure("config directory has no file metadata")); } restore_directory(child, path.as_str(), read, metadata, entries, total)? diff --git a/crates/content-history-store/src/lib.rs b/crates/content-history-store/src/lib.rs index 4c0d15f73..d4597a9c0 100644 --- a/crates/content-history-store/src/lib.rs +++ b/crates/content-history-store/src/lib.rs @@ -64,16 +64,23 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { } fn authorize(&self, access: Access) -> Result<()> { - if self.key.workspace.is_empty() || self.key.target.is_empty() + if self.key.workspace.is_empty() + || self.key.target.is_empty() || !self.authority.allows(&self.key, access) { return Err(Error::Denied); } - let erased: Option = self.tx.query_row( - "SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2", - params![self.key.workspace, self.key.target], |r| r.get(0), - ).optional()?; - if erased == Some(true) { return Err(Error::Erased); } + let erased: Option = self + .tx + .query_row( + "SELECT erased FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + |r| r.get(0), + ) + .optional()?; + if erased == Some(true) { + return Err(Error::Erased); + } Ok(()) } @@ -91,12 +98,21 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { } fn head_inner(&self) -> Result> { - let raw: Option> = self.tx.query_row( - "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", - params![self.key.workspace, self.key.target], |r| r.get(0), - ).optional()?.flatten(); - raw.map(|v| Ok(HistoryId::from_bytes(v.try_into().map_err(|_| Error::InvalidReference)?))) - .transpose() + let raw: Option> = self + .tx + .query_row( + "SELECT head FROM content_scopes WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + |r| r.get(0), + ) + .optional()? + .flatten(); + raw.map(|v| { + Ok(HistoryId::from_bytes( + v.try_into().map_err(|_| Error::InvalidReference)?, + )) + }) + .transpose() } /// Preparation requires the full canonical bytes, not possession of another scope's hash. @@ -110,11 +126,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { fn put_inner(&self, id: ObjectId, bytes: &[u8]) -> Result<()> { self.ensure_scope()?; let (kind, hash) = parts(id); - self.tx.execute("INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)", - params![kind, hash, bytes])?; + self.tx.execute( + "INSERT OR IGNORE INTO content_objects(kind,hash,bytes) VALUES(?1,?2,?3)", + params![kind, hash, bytes], + )?; let stored: Vec = self.tx.query_row( - "SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2", params![kind, hash], |r| r.get(0))?; - if stored != bytes { return Err(Error::ImmutableMismatch); } + "SELECT bytes FROM content_objects WHERE kind=?1 AND hash=?2", + params![kind, hash], + |r| r.get(0), + )?; + if stored != bytes { + return Err(Error::ImmutableMismatch); + } self.tx.execute("INSERT OR IGNORE INTO content_scope_objects(workspace,target,kind,hash) VALUES(?1,?2,?3,?4)", params![self.key.workspace, self.key.target, kind, hash])?; Ok(()) @@ -128,7 +151,9 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { let allowed: bool = self.tx.query_row( "SELECT EXISTS(SELECT 1 FROM content_reachable WHERE workspace=?1 AND target=?2 AND kind=?3 AND hash=?4)", params![self.key.workspace, self.key.target, kind, hash], |r| r.get(0))?; - if !allowed { return Err(Error::Unavailable); } + if !allowed { + return Err(Error::Unavailable); + } self.load_prepared(id) } @@ -149,18 +174,32 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { self.walk([ObjectId::Tree(record.root)], true) } - fn walk(&self, roots: impl IntoIterator, public: bool) -> Result> { + fn walk( + &self, + roots: impl IntoIterator, + public: bool, + ) -> Result> { let mut pending: Vec<_> = roots.into_iter().collect(); let mut objects = BTreeMap::new(); while let Some(id) = pending.pop() { - if objects.contains_key(&id) { continue; } - let object = if public { self.read(id)? } else { self.load_prepared(id)? }; + if objects.contains_key(&id) { + continue; + } + let object = if public { + self.read(id)? + } else { + self.load_prepared(id)? + }; match &object { - Object::Blob(_) => {}, - Object::Tree(tree) => pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))), + Object::Blob(_) => {} + Object::Tree(tree) => { + pending.extend(tree.entries().iter().map(|e| ObjectId::from(e.target))) + } Object::History(history) => { pending.push(ObjectId::Tree(history.root)); - if let Some(parent) = history.parent { pending.push(ObjectId::History(parent)); } + if let Some(parent) = history.parent { + pending.push(ObjectId::History(parent)); + } } } objects.insert(id, object); @@ -171,7 +210,13 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { /// Authenticated actor and clock facts belong to the caller, not restored post authors. /// Restoring an old root appends a history with the current parent; no rewind operation exists. /// The private savepoint prevents even a caught error from publishing a partial adoption. - pub fn adopt(&self, root: TreeId, expected: Option, actor: String, timestamp_micros: i64) -> Result { + pub fn adopt( + &self, + root: TreeId, + expected: Option, + actor: String, + timestamp_micros: i64, + ) -> Result { self.authorize(Access::Write)?; self.atomic(|| { if self.head_inner()? != expected { return Err(Error::Conflict); } @@ -215,9 +260,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { self.authorize(Access::Erase)?; self.atomic(|| { self.ensure_scope()?; - self.tx.execute("DELETE FROM content_reachable WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; - self.tx.execute("DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; - self.tx.execute("UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", params![self.key.workspace,self.key.target])?; + self.tx.execute( + "DELETE FROM content_reachable WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; + self.tx.execute( + "DELETE FROM content_scope_objects WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; + self.tx.execute( + "UPDATE content_scopes SET head=NULL,erased=1 WHERE workspace=?1 AND target=?2", + params![self.key.workspace, self.key.target], + )?; self.collect_physical() }) } @@ -227,12 +281,18 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { Ok(()) } - fn atomic(&self, f: impl FnOnce()->Result) -> Result { - self.tx.execute_batch("SAVEPOINT content_history_operation")?; + fn atomic(&self, f: impl FnOnce() -> Result) -> Result { + self.tx + .execute_batch("SAVEPOINT content_history_operation")?; match f() { - Ok(value) => { self.tx.execute_batch("RELEASE content_history_operation")?; Ok(value) }, + Ok(value) => { + self.tx.execute_batch("RELEASE content_history_operation")?; + Ok(value) + } Err(error) => { - self.tx.execute_batch("ROLLBACK TO content_history_operation; RELEASE content_history_operation")?; + self.tx.execute_batch( + "ROLLBACK TO content_history_operation; RELEASE content_history_operation", + )?; Err(error) } } @@ -241,8 +301,8 @@ impl<'a, 'conn> ScopedStore<'a, 'conn> { fn parts(id: ObjectId) -> (u8, Vec) { match id { - ObjectId::Blob(h) => (1,h.as_bytes().to_vec()), - ObjectId::Tree(h) => (2,h.as_bytes().to_vec()), - ObjectId::History(h) => (3,h.as_bytes().to_vec()), + ObjectId::Blob(h) => (1, h.as_bytes().to_vec()), + ObjectId::Tree(h) => (2, h.as_bytes().to_vec()), + ObjectId::History(h) => (3, h.as_bytes().to_vec()), } } From 267f4d68427fff3d931cc46b7aa2bfb76235fd9e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 11:04:33 +0900 Subject: [PATCH 4/7] Complete the pinned parity example module closure Adopt only tests/common/mod.rs from exact T835 checkpoint 29cb24a878fb2f7183b022da6d4316e137edd946. The already-adopted 223aa125 parity example references this missing module, preventing root cargo fmt. No in-progress product fixes or full test suites are incorporated. --- crates/content-history/tests/common/mod.rs | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 crates/content-history/tests/common/mod.rs diff --git a/crates/content-history/tests/common/mod.rs b/crates/content-history/tests/common/mod.rs new file mode 100644 index 000000000..5e715d665 --- /dev/null +++ b/crates/content-history/tests/common/mod.rs @@ -0,0 +1,77 @@ +use content_history::{ContentId, Entry, History, Object, ObjectId, Tree}; + +pub fn objects() -> Vec { + let empty = Object::Blob(Vec::new()); + let text = Object::Blob(b"a\r\n\xc3\xa9\0".to_vec()); + let ObjectId::Blob(empty_id) = empty.id() else { + unreachable!() + }; + let ObjectId::Blob(text_id) = text.id() else { + unreachable!() + }; + let entries = vec![ + Entry { + key: "é".into(), + target: ContentId::Blob(text_id), + }, + Entry { + key: "z".into(), + target: ContentId::Blob(empty_id), + }, + ]; + let map = Object::Tree(Tree::map(entries.clone()).unwrap()); + let sequence = Object::Tree(Tree::sequence(entries).unwrap()); + let ObjectId::Tree(root) = map.id() else { + unreachable!() + }; + let first = Object::History(History { + root, + parent: None, + actor: "user:編集".into(), + timestamp_micros: -1, + }); + let ObjectId::History(parent) = first.id() else { + unreachable!() + }; + let returned = Object::History(History { + root, + parent: Some(parent), + actor: "user:編集".into(), + timestamp_micros: 0, + }); + vec![ + empty, + text, + Object::Tree(Tree::map(vec![]).unwrap()), + Object::Tree(Tree::sequence(vec![]).unwrap()), + map, + sequence, + first, + returned, + Object::Tree( + Tree::map(vec![Entry { + key: "nested".into(), + target: ContentId::Tree(root), + }]) + .unwrap(), + ), + ] +} + +pub fn digest_bytes(object: &Object) -> [u8; 32] { + match object.id() { + ObjectId::Blob(id) => *id.as_bytes(), + ObjectId::Tree(id) => *id.as_bytes(), + ObjectId::History(id) => *id.as_bytes(), + } +} + +pub fn parity_word(index: u32) -> u32 { + let objects = objects(); + let Some(object) = objects.get(index as usize / 8) else { + return u32::MAX; + }; + let hash = digest_bytes(object); + let offset = (index as usize % 8) * 4; + u32::from_be_bytes(hash[offset..offset + 4].try_into().unwrap()) +} From a7f5ecb56a2b969c09fdbbf4c183f9da32f97291 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 11:19:17 +0900 Subject: [PATCH 5/7] Bind Feature declarations to Host handles and publish permission contract fixtures T836: registered Feature callback/namespace registrar, explicit children/catalog resolution, frozen child and Job key tests, payload-free text/storage/API/Web wire contract and generated OpenAPI/TypeScript. Real LocalWorkdirSession checks preserve path, recursion and symlink denial independently of allowed keys. No policy adoption/migration, real-operation authorization or Worker defaults. Validated: feature-permissions 22 tests and 3 doctests; worker declaration/provider filter 4; server-api permission filter 2; typed Deno Web 2; exact generated artifacts and format/diff/naming. Full root check/changed-crate closure and independent MR verdict remain to be run before final reviewed handoff. Pinned dependency format is exact rustfmt output, including formatter optional punctuation/closure braces, not a claim of bytewise non-whitespace token identity. --- crates/feature-permissions/README.md | 45 +- .../examples/generate_typescript.rs | 3 + crates/feature-permissions/src/registry.rs | 6 + .../tests/fixtures/assignment.json | 22 + .../tests/fixtures/assignment.toml | 17 + .../tests/fixtures/catalog.json | 45 + .../tests/fixtures/catalog.toml | 35 + .../tests/generated_contract.rs | 9 + .../feature-permissions/tests/resolution.rs | 912 ++++++++++++++++++ .../tests/wire_contract.rs | 287 ++++++ .../generate_feature_permissions_openapi.rs | 7 + crates/server-api/src/feature_permissions.rs | 140 +++ crates/server-api/src/lib.rs | 1 + crates/worker/src/feature.rs | 21 + crates/worker/src/feature/permissions.rs | 148 +++ .../worker/src/feature/permissions/tests.rs | 263 +++++ openapi/feature-permissions-api.json | 653 +++++++++++++ web/workspace/deno.json | 2 +- .../feature-permissions.generated.ts | 18 + .../lib/workspace/feature-permissions.test.ts | 80 ++ 20 files changed, 2711 insertions(+), 3 deletions(-) create mode 100644 crates/feature-permissions/examples/generate_typescript.rs create mode 100644 crates/feature-permissions/tests/fixtures/assignment.json create mode 100644 crates/feature-permissions/tests/fixtures/assignment.toml create mode 100644 crates/feature-permissions/tests/fixtures/catalog.json create mode 100644 crates/feature-permissions/tests/fixtures/catalog.toml create mode 100644 crates/feature-permissions/tests/generated_contract.rs create mode 100644 crates/feature-permissions/tests/resolution.rs create mode 100644 crates/feature-permissions/tests/wire_contract.rs create mode 100644 crates/server-api/examples/generate_feature_permissions_openapi.rs create mode 100644 crates/server-api/src/feature_permissions.rs create mode 100644 crates/worker/src/feature/permissions.rs create mode 100644 crates/worker/src/feature/permissions/tests.rs create mode 100644 openapi/feature-permissions-api.json create mode 100644 web/workspace/src/lib/generated/feature-permissions.generated.ts create mode 100644 web/workspace/src/lib/workspace/feature-permissions.test.ts diff --git a/crates/feature-permissions/README.md b/crates/feature-permissions/README.md index 31b3fadd3..700545288 100644 --- a/crates/feature-permissions/README.md +++ b/crates/feature-permissions/README.md @@ -56,5 +56,46 @@ A text/storage/management assignment uses an explicit discriminant, for example: The same Serde types validate dynamic inputs; unknown fields (including payloads) are rejected. [`typescript`] generates the Web contract. Management projections are serializable observations, not credentials: deserialization cannot manufacture a -runtime resolution, namespace handle or delegation grant. The consumer owns -policy persistence and its route/subject authentication, not this DTO layer. +runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not +this DTO layer. + +To add a Feature, the trusted loader registers the established identity and gives +its registrar a namespace handle. The declaration contains only operation keys +and explicit edges; no central domain list needs updating: + +```rust +use feature_permissions::*; +let mut host = PermissionHost::new(WorkspaceId::parse("workspace-a")?); +// Host-only: a Plugin receives its bound registrar, not this registration API. +let feature = host.register_feature(FeatureIdentity::parse("plugin:example")?)?; +let namespace = host.bind_namespace(&feature, NamespaceId::parse("example")?)?; +let read = PermissionKey::parse("example.documents.read")?; +host.declare(&namespace, NamespaceDeclaration { + permissions: vec![PermissionDefinition { + key: read.clone(), description: "Read example documents".into(), children: vec![], + }], + sets: vec![SetContribution { + set: PermissionSetId::parse("companion")?, + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse("example")?, keys: vec![read.clone()], + })], + }], +})?; +let assignment = PermissionAssignment { + grants: vec![PermissionReference::Set(PermissionSetId::parse("companion")?)], + ..Default::default() +}; +let resolved = host.resolve(Some(&assignment)); +assert!(host.allows(&namespace, &resolved, &read)?); +# Ok::<(), Box>(()) +``` + +Wire inputs cannot manufacture an owner handle or executable resolution: + +```compile_fail +let _: feature_permissions::NamespaceHandle = serde_json::from_str("{}").unwrap(); +``` + +```compile_fail +let _: feature_permissions::ResolvedPermissions = serde_json::from_str("{}").unwrap(); +``` diff --git a/crates/feature-permissions/examples/generate_typescript.rs b/crates/feature-permissions/examples/generate_typescript.rs new file mode 100644 index 000000000..cc78f7a26 --- /dev/null +++ b/crates/feature-permissions/examples/generate_typescript.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", feature_permissions::typescript()); +} diff --git a/crates/feature-permissions/src/registry.rs b/crates/feature-permissions/src/registry.rs index f00012637..af9cb8d3e 100644 --- a/crates/feature-permissions/src/registry.rs +++ b/crates/feature-permissions/src/registry.rs @@ -50,6 +50,12 @@ impl NamespaceHandle { pub fn namespace(&self) -> &NamespaceId { &self.namespace } + + /// Compare the actual registrations, never caller-supplied owner names. + pub fn is_owned_by(&self, feature: &FeatureHandle) -> bool { + Arc::ptr_eq(&self.feature.host, &feature.host) + && Arc::ptr_eq(&self.feature.registration, &feature.registration) + } } #[derive(Clone, Debug)] struct FeatureRecord { diff --git a/crates/feature-permissions/tests/fixtures/assignment.json b/crates/feature-permissions/tests/fixtures/assignment.json new file mode 100644 index 000000000..62288bcb5 --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/assignment.json @@ -0,0 +1,22 @@ +{ + "grants": [ + { "kind": "set", "value": "ticket" }, + { + "kind": "namespace", + "value": { + "namespace": "ticket", + "keys": ["ticket.hoge", "ticket.hoge.fuga.finish"] + } + } + ], + "exclusions": [ + { + "kind": "namespace", + "value": { + "namespace": "ticket", + "keys": ["ticket.hoge.unlinked"] + } + } + ], + "revoked": false +} diff --git a/crates/feature-permissions/tests/fixtures/assignment.toml b/crates/feature-permissions/tests/fixtures/assignment.toml new file mode 100644 index 000000000..97ccd3b1c --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/assignment.toml @@ -0,0 +1,17 @@ +revoked = false + +[[grants]] +kind = "set" +value = "ticket" + +[[grants]] +kind = "namespace" +[grants.value] +namespace = "ticket" +keys = ["ticket.hoge", "ticket.hoge.fuga.finish"] + +[[exclusions]] +kind = "namespace" +[exclusions.value] +namespace = "ticket" +keys = ["ticket.hoge.unlinked"] diff --git a/crates/feature-permissions/tests/fixtures/catalog.json b/crates/feature-permissions/tests/fixtures/catalog.json new file mode 100644 index 000000000..fab83bc8c --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/catalog.json @@ -0,0 +1,45 @@ +{ + "workspace": "workspace-a", + "namespaces": [ + { + "namespace": "ticket", + "feature": "builtin:ticket", + "enabled": true, + "declaration": { + "permissions": [ + { + "key": "ticket.hoge", + "description": "Root operation with explicit children", + "children": ["ticket.hoge.fuga"] + }, + { + "key": "ticket.hoge.fuga", + "description": "Intermediate operation with an explicit child", + "children": ["ticket.hoge.fuga.finish"] + }, + { + "key": "ticket.hoge.fuga.finish", + "description": "Leaf operation", + "children": [] + }, + { + "key": "ticket.hoge.unlinked", + "description": "Same prefix without an inclusion edge", + "children": [] + } + ], + "sets": [ + { + "set": "ticket", + "children": [ + { + "kind": "namespace", + "value": { "namespace": "ticket", "keys": ["ticket.hoge"] } + } + ] + } + ] + } + } + ] +} diff --git a/crates/feature-permissions/tests/fixtures/catalog.toml b/crates/feature-permissions/tests/fixtures/catalog.toml new file mode 100644 index 000000000..6537e2721 --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/catalog.toml @@ -0,0 +1,35 @@ +workspace = "workspace-a" + +[[namespaces]] +namespace = "ticket" +feature = "builtin:ticket" +enabled = true + +[[namespaces.declaration.permissions]] +key = "ticket.hoge" +description = "Root operation with explicit children" +children = ["ticket.hoge.fuga"] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.fuga" +description = "Intermediate operation with an explicit child" +children = ["ticket.hoge.fuga.finish"] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.fuga.finish" +description = "Leaf operation" +children = [] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.unlinked" +description = "Same prefix without an inclusion edge" +children = [] + +[[namespaces.declaration.sets]] +set = "ticket" + +[[namespaces.declaration.sets.children]] +kind = "namespace" +[namespaces.declaration.sets.children.value] +namespace = "ticket" +keys = ["ticket.hoge"] diff --git a/crates/feature-permissions/tests/generated_contract.rs b/crates/feature-permissions/tests/generated_contract.rs new file mode 100644 index 000000000..485e43e42 --- /dev/null +++ b/crates/feature-permissions/tests/generated_contract.rs @@ -0,0 +1,9 @@ +//! The generated Web contract is an external artifact, not a second hand-written +//! list of domains. Normal crate tests reject drift from the declaration types. +#[test] +fn checked_in_web_contract_matches_the_payload_free_rust_types() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/feature-permissions.generated.ts"); + let saved = std::fs::read_to_string(path).expect("run the generate_typescript example"); + assert_eq!(saved, feature_permissions::typescript()); +} diff --git a/crates/feature-permissions/tests/resolution.rs b/crates/feature-permissions/tests/resolution.rs new file mode 100644 index 000000000..668d73666 --- /dev/null +++ b/crates/feature-permissions/tests/resolution.rs @@ -0,0 +1,912 @@ +//! Core component tests through the public API only. These do not stand in for +//! execution-context/provider integration or actual operation authorization. + +use feature_permissions::*; +use std::collections::BTreeSet; + +fn key(value: &str) -> PermissionKey { + PermissionKey::parse(value).unwrap() +} + +fn namespace(value: &str) -> NamespaceId { + NamespaceId::parse(value).unwrap() +} + +fn set(value: &str) -> PermissionReference { + PermissionReference::Set(PermissionSetId::parse(value).unwrap()) +} + +fn keys(ns: &str, values: &[&str]) -> PermissionReference { + PermissionReference::Namespace(NamespacePermissions { + namespace: namespace(ns), + keys: values.iter().map(|value| key(value)).collect(), + }) +} + +fn assignment(grants: Vec) -> PermissionAssignment { + PermissionAssignment { + grants, + ..PermissionAssignment::default() + } +} + +fn host(workspace: &str) -> PermissionHost { + PermissionHost::new(WorkspaceId::parse(workspace).unwrap()) +} + +fn register( + host: &mut PermissionHost, + identity: &str, + ns: &str, +) -> (FeatureHandle, NamespaceHandle) { + let feature = host + .register_feature(FeatureIdentity::parse(identity).unwrap()) + .unwrap(); + let handle = host.bind_namespace(&feature, namespace(ns)).unwrap(); + (feature, handle) +} + +fn declaration( + definitions: &[(&str, &[&str])], + contributions: &[(&str, Vec)], +) -> NamespaceDeclaration { + NamespaceDeclaration { + permissions: definitions + .iter() + .map(|(name, children)| PermissionDefinition { + key: key(name), + description: format!("Operation {name}"), + children: children.iter().map(|child| key(child)).collect(), + }) + .collect(), + sets: contributions + .iter() + .map(|(name, children)| SetContribution { + set: PermissionSetId::parse(*name).unwrap(), + children: children.clone(), + }) + .collect(), + } +} + +fn assert_keys(resolved: &ResolvedPermissions, expected: &[&str]) { + assert_eq!( + resolved + .projection() + .allowed + .keys() + .cloned() + .collect::>(), + expected.iter().map(|value| key(value)).collect() + ); +} + +fn has_origin(resolved: &ResolvedPermissions, operation: &str, feature: &str, set_name: &str) { + let paths = &resolved.projection().allowed[&key(operation)]; + assert!(!paths.is_empty()); + for path in paths { + assert!(path.contains(&OriginStep::Feature( + FeatureIdentity::parse(feature).unwrap() + ))); + assert!(path.contains(&OriginStep::Set(PermissionSetId::parse(set_name).unwrap()))); + assert_eq!(path.last(), Some(&OriginStep::Key(key(operation)))); + } +} + +#[test] +fn existing_set_holder_tracks_two_builtins_and_trusted_plugin_without_redistribution() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (_, workdir) = register(&mut host, "builtin:workdir", "workdir"); + host.declare( + &ticket, + declaration( + &[("ticket.read", &[]), ("ticket.write", &[])], + &[("companion", vec![keys("ticket", &["ticket.read"])])], + ), + ) + .unwrap(); + host.declare( + &workdir, + declaration( + &[("workdir.read", &[])], + &[("companion", vec![keys("workdir", &["workdir.read"])])], + ), + ) + .unwrap(); + // This same stored assignment is reused throughout; no per-subject update. + let holder = assignment(vec![set("companion"), keys("ticket", &["ticket.write"])]); + let before = host.resolve(Some(&holder)); + assert_keys(&before, &["ticket.read", "ticket.write", "workdir.read"]); + + let (plugin_feature, plugin) = register(&mut host, "plugin:external-search@1", "search"); + host.declare( + &plugin, + declaration( + &[("search.query.deep", &[])], + &[("companion", vec![keys("search", &["search.query.deep"])])], + ), + ) + .unwrap(); + let installed = host.resolve(Some(&holder)); + assert_keys( + &installed, + &[ + "ticket.read", + "ticket.write", + "workdir.read", + "search.query.deep", + ], + ); + has_origin(&installed, "ticket.read", "builtin:ticket", "companion"); + has_origin(&installed, "workdir.read", "builtin:workdir", "companion"); + has_origin( + &installed, + "search.query.deep", + "plugin:external-search@1", + "companion", + ); + assert_eq!( + host.allows(&ticket, &before, &key("ticket.read")), + Err(PermissionError::StaleResolution) + ); + + host.set_enabled(&plugin_feature, false).unwrap(); + let disabled = host.resolve(Some(&holder)); + assert_keys(&disabled, &["ticket.read", "ticket.write", "workdir.read"]); + assert_eq!( + disabled.projection().diagnostics, + vec![PermissionDiagnostic::DisabledFeature( + FeatureIdentity::parse("plugin:external-search@1").unwrap() + )] + ); + let direct_plugin = assignment(vec![keys("search", &["search.query.deep"])]); + assert_keys(&host.resolve(Some(&direct_plugin)), &[]); + host.set_enabled(&plugin_feature, true).unwrap(); + assert_keys( + &host.resolve(Some(&holder)), + &[ + "ticket.read", + "ticket.write", + "workdir.read", + "search.query.deep", + ], + ); + host.remove_feature(&plugin_feature).unwrap(); + let removed = host.resolve(Some(&holder)); + assert_keys(&removed, &["ticket.read", "ticket.write", "workdir.read"]); + assert!(removed.projection().diagnostics.is_empty()); + assert!( + !host + .catalog() + .namespaces + .iter() + .any(|ns| ns.namespace == namespace("search")) + ); + assert_eq!( + host.allows(&plugin, &removed, &key("search.query.deep")), + Err(PermissionError::ForeignHandle) + ); +} + +#[test] +fn ownership_comes_from_handles_not_raw_owner_fields_or_namespace_spelling() { + let mut host = host("workspace-a"); + let (builtin, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + let baseline = host.catalog(); + assert_eq!( + host.bind_namespace(&plugin, namespace("ticket")) + .unwrap_err(), + PermissionError::NamespaceCollision(namespace("ticket")) + ); + assert_eq!( + host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()) + .unwrap_err(), + PermissionError::FeatureCollision(FeatureIdentity::parse("builtin:ticket").unwrap()) + ); + assert_eq!( + host.declare(&search, declaration(&[("ticket.read", &[])], &[])), + Err(PermissionError::Ownership(key("ticket.read"))) + ); + // The rule is symmetric: builtin identity is not permission to impersonate a Plugin. + assert_eq!( + host.declare(&ticket, declaration(&[("search.query", &[])], &[])), + Err(PermissionError::Ownership(key("search.query"))) + ); + // A set contribution is no alternate way to declare another Feature's keys. + assert_eq!( + host.declare( + &search, + declaration( + &[], + &[("companion", vec![keys("ticket", &["ticket.read"])])] + ), + ), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + host.declare( + &search, + declaration( + &[], + &[("companion", vec![keys("search", &["ticket.read"])])] + ), + ), + Err(PermissionError::Ownership(key("ticket.read"))) + ); + assert!( + serde_json::from_str::( + r#"{"owner":"builtin:ticket","permissions":[],"sets":[]}"# + ) + .is_err() + ); + assert_eq!(host.catalog(), baseline); + host.remove_feature(&builtin).unwrap(); + let (_, replacement) = register(&mut host, "builtin:ticket", "ticket"); + assert_eq!( + host.declare(&ticket, declaration(&[], &[])), + Err(PermissionError::ForeignHandle) + ); + host.declare(&replacement, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); +} + +#[test] +fn namespace_registration_and_empty_namespace_assignment_do_not_grant_operations() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + for policy in [None, Some(assignment(vec![keys("ticket", &[])]))] { + let resolved = host.resolve(policy.as_ref()); + assert_keys(&resolved, &[]); + assert_eq!( + host.allows(&ticket, &resolved, &key("ticket.read")), + Ok(false) + ); + } +} + +#[test] +fn handles_and_results_are_not_portable_even_between_hosts_with_the_same_workspace_id() { + let mut original = host("workspace-a"); + let (feature, ticket) = register(&mut original, "builtin:ticket", "ticket"); + original + .declare(&ticket, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let resolved = original.resolve(Some(&policy)); + let child = original + .issue_delegation(&resolved, &[key("ticket.read")]) + .unwrap(); + let job = original + .issue_job_grant("job-a".into(), &resolved, &[key("ticket.read")]) + .unwrap(); + for workspace in ["workspace-a", "workspace-b"] { + let mut other = host(workspace); + let (_, local) = register(&mut other, "builtin:ticket", "ticket"); + other + .declare(&local, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + let local_result = other.resolve(Some(&policy)); + let local_child = other + .issue_delegation(&local_result, &[key("ticket.read")]) + .unwrap(); + assert_eq!( + other + .bind_namespace(&feature, namespace("foreign")) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other.set_enabled(&feature, false), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.remove_feature(&feature), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.declare(&ticket, declaration(&[], &[])), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.allows(&ticket, &local_result, &key("ticket.read")), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.allows(&local, &resolved, &key("ticket.read")), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other + .issue_delegation(&resolved, &[key("ticket.read")]) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other + .resolve_limited(&local_result, &local_result, &child) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other + .resolve_job("job-a", &local_result, &local_result, &local_child, &job) + .unwrap_err(), + PermissionError::ForeignHandle + ); + } +} + +fn diamond() -> NamespaceDeclaration { + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.fuga", "ticket.branch"]), + ("ticket.hoge.fuga", &["ticket.hoge.fuga.deep"]), + ("ticket.branch", &["ticket.hoge.fuga.deep"]), + ("ticket.hoge.fuga.deep", &[]), + ("ticket.hoge.unrelated", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ) +} + +#[test] +fn explicit_multilevel_children_keep_distinct_paths_but_never_infer_a_prefix_grant() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let policy = assignment(vec![set("companion"), set("companion")]); + let resolved = host.resolve(Some(&policy)); + assert_keys( + &resolved, + &[ + "ticket.hoge", + "ticket.hoge.fuga", + "ticket.branch", + "ticket.hoge.fuga.deep", + ], + ); + assert_eq!( + host.allows(&ticket, &resolved, &key("ticket.hoge.unrelated")), + Ok(false) + ); + let paths = &resolved.projection().allowed[&key("ticket.hoge.fuga.deep")]; + assert_eq!( + paths.len(), + 2, + "duplicate grants must not duplicate provenance" + ); + let key_paths: BTreeSet> = paths + .iter() + .map(|path| { + path.iter() + .filter_map(|step| match step { + OriginStep::Key(key) => Some(key.clone()), + _ => None, + }) + .collect() + }) + .collect(); + assert_eq!( + key_paths, + BTreeSet::from([ + vec![ + key("ticket.hoge"), + key("ticket.hoge.fuga"), + key("ticket.hoge.fuga.deep") + ], + vec![ + key("ticket.hoge"), + key("ticket.branch"), + key("ticket.hoge.fuga.deep") + ], + ]) + ); + has_origin( + &resolved, + "ticket.hoge.fuga.deep", + "builtin:ticket", + "companion", + ); + assert_eq!( + resolved.projection(), + host.resolve(Some(&policy)).projection(), + "resolution and provenance ordering must be deterministic" + ); +} + +#[test] +fn invalid_graph_updates_reject_atomically_without_retiring_a_valid_result() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let baseline = host.catalog(); + let policy = assignment(vec![set("companion")]); + let valid = host.resolve(Some(&policy)); + let mut dangling_removal = diamond(); + dangling_removal + .permissions + .retain(|definition| definition.key != key("ticket.hoge.fuga.deep")); + let invalid = [ + (dangling_removal, "unknown_key"), + (declaration(&[("ticket.a", &["ticket.a"])], &[]), "cycle"), + ( + declaration( + &[("ticket.a", &["ticket.b"]), ("ticket.b", &["ticket.a"])], + &[], + ), + "cycle", + ), + ( + declaration(&[("ticket.a", &["ticket.missing"])], &[]), + "unknown_key", + ), + ( + declaration(&[], &[("a", vec![set("missing")])]), + "unknown_set", + ), + ( + declaration(&[], &[("a", vec![set("b")]), ("b", vec![set("a")])]), + "cycle", + ), + ( + declaration(&[("ticket.a", &[]), ("ticket.a", &[])], &[]), + "duplicate", + ), + ( + declaration( + &[("ticket.a", &["ticket.b", "ticket.b"]), ("ticket.b", &[])], + &[], + ), + "duplicate", + ), + ( + declaration(&[], &[("a", vec![]), ("a", vec![])]), + "duplicate", + ), + ( + declaration(&[], &[("a", vec![keys("ticket", &["ticket.missing"])])]), + "unknown_key", + ), + ]; + for (candidate, expected) in invalid { + let error = host.declare(&ticket, candidate).unwrap_err(); + assert!( + matches!( + (&error, expected), + (PermissionError::Cycle(_), "cycle") + | (PermissionError::UnknownKey(_), "unknown_key") + | (PermissionError::UnknownSet(_), "unknown_set") + | (PermissionError::Duplicate(_), "duplicate") + ), + "unexpected rejection: {error:?} for {expected}" + ); + assert_eq!(host.catalog(), baseline); + assert_eq!( + host.allows(&ticket, &valid, &key("ticket.hoge.fuga.deep")), + Ok(true) + ); + assert_eq!(host.resolve(Some(&policy)).projection(), valid.projection()); + } +} + +#[test] +fn changing_and_removing_children_withdraws_old_permissions_and_rejects_stale_results() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.hoge"])]); + let old = host.resolve(Some(&policy)); + host.declare( + &ticket, + declaration(&[("ticket.hoge", &[]), ("ticket.hoge.fuga", &[])], &[]), + ) + .unwrap(); + assert_eq!( + host.allows(&ticket, &old, &key("ticket.hoge.fuga.deep")), + Err(PermissionError::StaleResolution) + ); + let fresh = host.resolve(Some(&policy)); + assert_keys(&fresh, &["ticket.hoge"]); + assert_eq!( + host.allows(&ticket, &fresh, &key("ticket.hoge.fuga")), + Ok(false) + ); + let withdrawn = assignment(vec![keys("ticket", &["ticket.hoge.fuga.deep"])]); + let withdrawn_result = host.resolve(Some(&withdrawn)); + assert_keys(&withdrawn_result, &[]); + assert_eq!( + withdrawn_result.projection().diagnostics, + vec![PermissionDiagnostic::UnknownKey(key( + "ticket.hoge.fuga.deep" + ))] + ); + host.declare(&ticket, declaration(&[], &[])).unwrap(); + assert_keys(&host.resolve(Some(&policy)), &[]); +} + +#[test] +fn exclusions_expand_children_and_win_over_all_paths_without_reviving_defaults() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let mut policy = assignment(vec![ + set("companion"), + keys("ticket", &["ticket.hoge.fuga.deep"]), + ]); + policy.exclusions = vec![keys("ticket", &["ticket.hoge.fuga"])]; + let resolved = host.resolve(Some(&policy)); + assert_keys(&resolved, &["ticket.hoge", "ticket.branch"]); + assert_eq!( + resolved.projection().diagnostics, + vec![ + PermissionDiagnostic::Excluded(key("ticket.hoge.fuga")), + PermissionDiagnostic::Excluded(key("ticket.hoge.fuga.deep")), + ] + ); + // A child request expands through the same graph and cannot bypass exclusion. + assert_eq!( + host.issue_delegation(&resolved, &[key("ticket.hoge")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.hoge.fuga")) + ); + policy.exclusions = vec![set("companion")]; + assert_keys(&host.resolve(Some(&policy)), &[]); + policy.revoked = true; + let revoked = host.resolve(Some(&policy)); + assert_keys(&revoked, &[]); + assert_eq!( + revoked.projection().diagnostics, + vec![PermissionDiagnostic::Revoked] + ); + let unset = host.resolve(None); + assert_keys(&unset, &[]); + assert_eq!( + unset.projection().diagnostics, + vec![PermissionDiagnostic::Unset] + ); + let empty = host.resolve(Some(&PermissionAssignment::default())); + assert_keys(&empty, &[]); + assert!(empty.projection().diagnostics.is_empty()); +} + +#[test] +fn invalid_assignment_references_deny_the_whole_assignment_with_deterministic_diagnostics() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + for (invalid, diagnostic) in [ + ( + set("missing"), + PermissionDiagnostic::UnknownSet(PermissionSetId::parse("missing").unwrap()), + ), + ( + keys("absent", &[]), + PermissionDiagnostic::UnknownNamespace(namespace("absent")), + ), + ( + keys("ticket", &["ticket.missing"]), + PermissionDiagnostic::UnknownKey(key("ticket.missing")), + ), + ( + keys("ticket", &["search.query"]), + PermissionDiagnostic::UnknownKey(key("search.query")), + ), + ] { + for as_exclusion in [false, true] { + let mut policy = assignment(vec![set("companion")]); + if as_exclusion { + policy.exclusions = vec![invalid.clone(), invalid.clone()]; + } else { + policy.grants.extend([invalid.clone(), invalid.clone()]); + } + let resolved = host.resolve(Some(&policy)); + assert_keys(&resolved, &[]); + assert_eq!(resolved.projection().diagnostics, vec![diagnostic.clone()]); + assert_eq!( + resolved.projection(), + host.resolve(Some(&policy)).projection() + ); + } + } +} + +#[test] +fn removed_feature_leaves_dangling_children_diagnostic_instead_of_using_cached_permissions() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + host.declare(&search, declaration(&[("search.query", &[])], &[])) + .unwrap(); + host.declare( + &ticket, + declaration(&[("ticket.read", &["search.query"])], &[]), + ) + .unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let old = host.resolve(Some(&policy)); + assert_keys(&old, &["ticket.read", "search.query"]); + host.remove_feature(&plugin).unwrap(); + assert_eq!( + host.allows(&ticket, &old, &key("ticket.read")), + Err(PermissionError::StaleResolution) + ); + let fresh = host.resolve(Some(&policy)); + assert_keys(&fresh, &[]); + assert_eq!( + fresh.projection().diagnostics, + vec![PermissionDiagnostic::UnknownKey(key("search.query"))] + ); +} + +#[test] +fn feature_reregistration_does_not_revive_old_child_or_job_grants_for_identical_keys() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration( + &[("ticket.read", &[])], + &[("companion", vec![keys("ticket", &["ticket.read"])])], + ), + ) + .unwrap(); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + let plugin_declaration = declaration( + &[("search.query", &[])], + &[("companion", vec![keys("search", &["search.query"])])], + ); + host.declare(&search, plugin_declaration.clone()).unwrap(); + let holder = assignment(vec![set("companion")]); + let requested = [key("ticket.read"), key("search.query")]; + let original = host.resolve(Some(&holder)); + let old_child = host.issue_delegation(&original, &requested).unwrap(); + let old_job = host + .issue_job_grant("job-a".into(), &original, &requested) + .unwrap(); + assert_keys( + &host + .resolve_job("job-a", &original, &original, &old_child, &old_job) + .unwrap(), + &["ticket.read", "search.query"], + ); + + host.remove_feature(&plugin).unwrap(); + let removed = host.resolve(Some(&holder)); + assert_keys(&removed, &["ticket.read"]); + assert_keys( + &host + .resolve_limited(&removed, &removed, &old_child) + .unwrap(), + &["ticket.read"], + ); + // Reuse every public identifier and declaration, but not the registration. + let (_, replacement) = register(&mut host, "plugin:search", "search"); + host.declare(&replacement, plugin_declaration).unwrap(); + let current = host.resolve(Some(&holder)); + assert_keys(¤t, &["ticket.read", "search.query"]); + assert_eq!( + host.allows(&replacement, ¤t, &key("search.query")), + Ok(true) + ); + assert_eq!( + host.allows(&replacement, &original, &key("search.query")), + Err(PermissionError::StaleResolution) + ); + // The key snapshot still includes search.query; its old registration is the + // reason it cannot authorize the replacement. Unrelated Feature keys survive. + assert_eq!(old_child.keys(), &BTreeSet::from(requested.clone())); + assert_keys( + &host + .resolve_limited(¤t, ¤t, &old_child) + .unwrap(), + &["ticket.read"], + ); + let fresh_child = host.issue_delegation(¤t, &requested).unwrap(); + let fresh_job = host + .issue_job_grant("job-a".into(), ¤t, &requested) + .unwrap(); + // A fresh child isolates the old Job token: the old child cannot mask a + // missing registration check in JobGrant. Conversely use a fresh Job below. + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &fresh_child, &old_job) + .unwrap(), + &["ticket.read"], + ); + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &old_child, &fresh_job) + .unwrap(), + &["ticket.read"], + ); + // Positive control: reissuance, not identifier reuse, grants the new operation. + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &fresh_child, &fresh_job) + .unwrap(), + &["ticket.read", "search.query"], + ); +} + +#[test] +fn issued_child_and_job_keys_are_frozen_across_set_and_children_expansion() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.read"]), + ("ticket.hoge.read", &[]), + ("ticket.hoge.write", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ), + ) + .unwrap(); + let holder = assignment(vec![set("companion")]); + let old = host.resolve(Some(&holder)); + let child = host.issue_delegation(&old, &[key("ticket.hoge")]).unwrap(); + let job = host + .issue_job_grant("job-a".into(), &old, &[key("ticket.hoge")]) + .unwrap(); + let narrow_job = host + .issue_job_grant("job-b".into(), &old, &[key("ticket.hoge.read")]) + .unwrap(); + let frozen = BTreeSet::from([key("ticket.hoge"), key("ticket.hoge.read")]); + assert_eq!(child.keys(), &frozen); + + host.declare( + &ticket, + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.read", "ticket.hoge.write"]), + ("ticket.hoge.read", &[]), + ("ticket.hoge.write", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ), + ) + .unwrap(); + let (_, plugin) = register(&mut host, "plugin:search", "search"); + host.declare( + &plugin, + declaration( + &[("search.query", &[])], + &[("companion", vec![keys("search", &["search.query"])])], + ), + ) + .unwrap(); + assert_eq!( + host.issue_delegation(&old, &[key("ticket.hoge")]) + .unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_limited(&old, &old, &child).unwrap_err(), + PermissionError::StaleResolution + ); + let current = host.resolve(Some(&holder)); + assert_keys( + ¤t, + &[ + "ticket.hoge", + "ticket.hoge.read", + "ticket.hoge.write", + "search.query", + ], + ); + assert_eq!(child.keys(), &frozen); + let limited = host.resolve_limited(¤t, ¤t, &child).unwrap(); + assert_keys(&limited, &["ticket.hoge", "ticket.hoge.read"]); + assert_eq!( + host.resolve_limited(¤t, &old, &child).unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_limited(&old, ¤t, &child).unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_job("job-a", ¤t, &old, &child, &job) + .unwrap_err(), + PermissionError::StaleResolution + ); + let current_child = host + .issue_delegation(¤t, &[key("ticket.hoge"), key("search.query")]) + .unwrap(); + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, ¤t_child, &job) + .unwrap(), + &["ticket.hoge", "ticket.hoge.read"], + ); + assert_keys( + &host + .resolve_job("job-b", ¤t, ¤t, ¤t_child, &narrow_job) + .unwrap(), + &["ticket.hoge.read"], + ); + assert_eq!( + host.resolve_job("another-job", ¤t, ¤t, &child, &job) + .unwrap_err(), + PermissionError::WrongJob + ); +} + +#[test] +fn current_parent_execution_delegation_and_explicit_revocation_each_limit_children_and_jobs() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration(&[("ticket.read", &[]), ("ticket.write", &[])], &[]), + ) + .unwrap(); + let full_policy = assignment(vec![keys("ticket", &["ticket.read", "ticket.write"])]); + let narrow_policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let revoked_policy = PermissionAssignment { + revoked: true, + ..full_policy.clone() + }; + let full = host.resolve(Some(&full_policy)); + let narrow = host.resolve(Some(&narrow_policy)); + let revoked = host.resolve(Some(&revoked_policy)); + let mut child = host + .issue_delegation(&full, &[key("ticket.read"), key("ticket.write")]) + .unwrap(); + let mut job = host + .issue_job_grant( + "job-a".into(), + &full, + &[key("ticket.read"), key("ticket.write")], + ) + .unwrap(); + // Execution permission alone is not delegation authority (and vice versa). + for (execution, delegation, expected) in [ + (&full, &narrow, vec!["ticket.read"]), + (&narrow, &full, vec!["ticket.read"]), + (&full, &revoked, vec![]), + (&revoked, &full, vec![]), + ] { + assert_keys( + &host.resolve_limited(execution, delegation, &child).unwrap(), + &expected, + ); + assert_keys( + &host + .resolve_job("job-a", execution, delegation, &child, &job) + .unwrap(), + &expected, + ); + } + assert_eq!( + host.issue_delegation(&narrow, &[key("ticket.write")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.write")) + ); + assert_eq!( + host.issue_delegation(&full, &[key("ticket.missing")]) + .unwrap_err(), + PermissionError::UnknownKey(key("ticket.missing")) + ); + job.revoke(); + assert_eq!( + host.resolve_job("job-a", &full, &full, &child, &job) + .unwrap_err(), + PermissionError::RevokedGrant + ); + assert_keys( + &host.resolve_limited(&full, &full, &child).unwrap(), + &["ticket.read", "ticket.write"], + ); + child.revoke(); + assert_eq!( + host.resolve_limited(&full, &full, &child).unwrap_err(), + PermissionError::RevokedGrant + ); +} diff --git a/crates/feature-permissions/tests/wire_contract.rs b/crates/feature-permissions/tests/wire_contract.rs new file mode 100644 index 000000000..cd012dfed --- /dev/null +++ b/crates/feature-permissions/tests/wire_contract.rs @@ -0,0 +1,287 @@ +//! Public JSON/TOML boundary tests using the production Serde DTOs and shared Web +//! fixtures. No resolver, Host, storage, or authentication boundary is replaced or +//! exercised here. Run with `cargo test -p feature-permissions --test wire_contract`. + +use feature_permissions::{ + NamespaceCatalog, NamespaceDeclaration, NamespacePermissions, PermissionAssignment, + PermissionCatalog, PermissionDefinition, PermissionReference, SetContribution, + ValidatePermissionsRequest, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use std::fmt::Debug; + +const ASSIGNMENT_JSON: &str = include_str!("fixtures/assignment.json"); +const ASSIGNMENT_TOML: &str = include_str!("fixtures/assignment.toml"); +const CATALOG_JSON: &str = include_str!("fixtures/catalog.json"); +const CATALOG_TOML: &str = include_str!("fixtures/catalog.toml"); + +fn paired_fixture(json: &str, toml: &str) -> T +where + T: DeserializeOwned + Serialize + PartialEq + Debug, +{ + let from_json: T = serde_json::from_str(json).expect("valid JSON fixture"); + let from_toml: T = toml::from_str(toml).expect("valid TOML fixture"); + assert_eq!( + from_json, from_toml, + "text formats must have the same meaning" + ); + assert_eq!( + serde_json::to_value(&from_json).unwrap(), + serde_json::from_str::(json).unwrap(), + "serialization must not drop or synthesize wire fields" + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&from_toml).unwrap()).unwrap(), + from_json + ); + assert_eq!( + toml::from_str::(&toml::to_string(&from_json).unwrap()).unwrap(), + from_toml + ); + from_json +} + +fn reject_in_both_formats(rule: &str, input: Value) { + assert!( + serde_json::from_value::(input.clone()).is_err(), + "JSON accepted {rule}: {input}" + ); + let text = toml::to_string(&input).expect("rejection case must be representable as TOML"); + assert!( + toml::from_str::(&text).is_err(), + "TOML accepted {rule}: {text}" + ); +} + +fn reject_authority_and_payload_fields(input: Value) { + for (field, value) in [ + ("payload", json!({ "path": "/untrusted" })), + ("owner", json!("other-feature")), + ("workspace", json!("other-workspace")), + ] { + let mut modified = input.clone(); + modified + .as_object_mut() + .unwrap() + .insert(field.into(), value); + reject_in_both_formats::(field, modified); + } +} + +#[test] +fn assignment_text_formats_preserve_discriminants_and_multisegment_keys() { + let assignment: PermissionAssignment = paired_fixture(ASSIGNMENT_JSON, ASSIGNMENT_TOML); + // The same spelling is intentional: the tag, not a prefix, chooses the kind. + assert!( + matches!(&assignment.grants[0], PermissionReference::Set(id) if id.as_str() == "ticket") + ); + let PermissionReference::Namespace(namespace) = &assignment.grants[1] else { + panic!("namespace reference lost its discriminant"); + }; + assert_eq!(namespace.namespace.as_str(), "ticket"); + assert_eq!( + namespace + .keys + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["ticket.hoge", "ticket.hoge.fuga.finish"] + ); +} + +#[test] +fn namespace_catalog_roundtrip_preserves_only_explicit_multilevel_children() { + let catalog: PermissionCatalog = paired_fixture(CATALOG_JSON, CATALOG_TOML); + let namespace = &catalog.namespaces[0]; + // Also roundtrip the standalone management DTO, not only its outer envelope. + let standalone: NamespaceCatalog = + serde_json::from_value(serde_json::to_value(namespace).unwrap()).unwrap(); + assert_eq!(&standalone, namespace); + let permissions = &standalone.declaration.permissions; + let edges: Vec<_> = permissions + .iter() + .map(|permission| { + ( + permission.key.as_str(), + permission + .children + .iter() + .map(|key| key.as_str()) + .collect::>(), + ) + }) + .collect(); + assert_eq!( + edges, + [ + ("ticket.hoge", vec!["ticket.hoge.fuga"]), + ("ticket.hoge.fuga", vec!["ticket.hoge.fuga.finish"]), + ("ticket.hoge.fuga.finish", vec![]), + ("ticket.hoge.unlinked", vec![]), + ] + ); + assert_eq!( + serde_json::to_value(&standalone.declaration.sets[0]).unwrap(), + json!({ + "set": "ticket", + "children": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": ["ticket.hoge"]}}] + }) + ); +} + +#[test] +fn explicit_empty_and_revocation_survive_without_defaults() { + for input in [ + json!({"grants": [], "exclusions": [], "revoked": false}), + json!({"grants": [], "exclusions": [], "revoked": true}), + json!({ + "grants": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": []}}], + "exclusions": [], + "revoked": false + }), + ] { + paired_fixture::( + &serde_json::to_string(&input).unwrap(), + &toml::to_string(&input).unwrap(), + ); + } + for missing in ["grants", "exclusions", "revoked"] { + let mut input = json!({"grants": [], "exclusions": [], "revoked": false}); + input.as_object_mut().unwrap().remove(missing); + reject_in_both_formats::(missing, input); + } +} + +#[test] +fn reference_kind_and_shape_must_match_in_json_and_toml() { + for (rule, input) in [ + ("unknown kind", json!({"kind": "all", "value": "ticket"})), + ( + "case-sensitive kind", + json!({"kind": "Set", "value": "ticket"}), + ), + ("missing kind", json!({"value": "ticket"})), + ("missing value", json!({"kind": "set"})), + ( + "set with namespace shape", + json!({"kind": "set", "value": {"namespace": "ticket", "keys": []}}), + ), + ( + "namespace with set shape", + json!({"kind": "namespace", "value": "ticket"}), + ), + ( + "namespace missing keys", + json!({"kind": "namespace", "value": {"namespace": "ticket"}}), + ), + ( + "keys are not a scalar", + json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": "ticket.hoge"}}), + ), + ( + "keys carry no data", + json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": [{"key": "ticket.hoge", "payload": {}}]}}), + ), + ] { + reject_in_both_formats::(rule, input); + } +} + +#[test] +fn external_identifiers_reject_invalid_segments_without_truncation() { + for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] { + reject_in_both_formats::( + "invalid set identifier", + json!({"kind": "set", "value": invalid}), + ); + reject_in_both_formats::( + "invalid namespace identifier", + json!({"namespace": invalid, "keys": []}), + ); + } + for invalid in [ + "ticket", + "ticket..read", + ".ticket.read", + "ticket.read.", + "ticket.Read", + "ticket.*", + "ticket.read path", + ] { + reject_in_both_formats::( + "invalid assigned key", + json!({"namespace": "ticket", "keys": [invalid]}), + ); + reject_in_both_formats::( + "invalid declared key", + json!({"key": invalid, "description": "", "children": []}), + ); + reject_in_both_formats::( + "invalid child key", + json!({"key": "ticket.hoge", "description": "", "children": [invalid]}), + ); + } + for (field, invalid) in [ + ("workspace", ""), + ("workspace", "other workspace"), + ("feature", ""), + ("feature", "other\nfeature"), + ] { + let mut catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap(); + if field == "workspace" { + catalog["workspace"] = json!(invalid); + } else { + catalog["namespaces"][0]["feature"] = json!(invalid); + } + reject_in_both_formats::("invalid catalog identity", catalog); + } +} + +#[test] +fn grant_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() { + let assignment: Value = serde_json::from_str(ASSIGNMENT_JSON).unwrap(); + reject_authority_and_payload_fields::(assignment.clone()); + for reference in assignment["grants"].as_array().unwrap() { + reject_authority_and_payload_fields::(reference.clone()); + } + reject_authority_and_payload_fields::( + assignment["grants"][1]["value"].clone(), + ); + let request = json!({"assignment": assignment}); + let parsed: ValidatePermissionsRequest = serde_json::from_value(request.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), request); + reject_authority_and_payload_fields::(request.clone()); + let mut nested = request; + nested["assignment"]["workspace"] = json!("other-workspace"); + reject_in_both_formats::("nested workspace spoof", nested); +} + +#[test] +fn assignment_fields_reject_untyped_grants_and_nonboolean_revocation() { + for (field, invalid) in [ + ("grants", json!("ticket.hoge")), + ("grants", json!([{"ticket.hoge": true}])), + ("exclusions", json!({})), + ("revoked", json!("false")), + ] { + let mut input = json!({"grants": [], "exclusions": [], "revoked": false}); + input[field] = invalid; + reject_in_both_formats::(field, input); + } +} + +#[test] +fn declaration_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() { + let catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap(); + let declaration = &catalog["namespaces"][0]["declaration"]; + reject_authority_and_payload_fields::(declaration.clone()); + reject_authority_and_payload_fields::( + declaration["permissions"][0].clone(), + ); + reject_authority_and_payload_fields::(declaration["sets"][0].clone()); + // A catalog reports authenticated identities; declaration inputs cannot supply them. + let mut input = declaration.clone(); + input["feature"] = json!("other-feature"); + reject_in_both_formats::("feature identity injection", input); +} diff --git a/crates/server-api/examples/generate_feature_permissions_openapi.rs b/crates/server-api/examples/generate_feature_permissions_openapi.rs new file mode 100644 index 000000000..f213ba2e4 --- /dev/null +++ b/crates/server-api/examples/generate_feature_permissions_openapi.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), Box> { + print!( + "{}", + server_api::feature_permissions::permission_contract_openapi()?.to_json()? + ); + Ok(()) +} diff --git a/crates/server-api/src/feature_permissions.rs b/crates/server-api/src/feature_permissions.rs new file mode 100644 index 000000000..48fbb8df4 --- /dev/null +++ b/crates/server-api/src/feature_permissions.rs @@ -0,0 +1,140 @@ +//! Declaration/resolution management transport only. These generated routes are +//! intentionally not mounted by this crate. The Workspace authority must bind +//! authenticated context, read current policy and authorize management access +//! before implementing/mounting them; no input can issue a namespace handle. + +use crate::{RepositoryApiError, ServerRequestContext}; +use feature_permissions::types::{ + PermissionCatalog, PermissionResolution, ValidatePermissionsRequest, +}; + +// Transparent wrappers keep the wire representation identical while the REST +// owner, not the resolver crate, attests to OpenAPI/Serde agreement. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionCatalogResponse(pub PermissionCatalog); +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionValidationRequest(pub ValidatePermissionsRequest); +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionResolutionResponse(pub PermissionResolution); +impl api_macros::openapi::OpenApiSchema for PermissionCatalogResponse {} +impl api_macros::openapi::OpenApiSchema for PermissionValidationRequest {} +impl api_macros::openapi::OpenApiSchema for PermissionResolutionResponse {} + +#[api_macros::api(reqwest, axum, openapi)] +pub trait FeaturePermissionsApi { + #[get("/api/w/{workspace_id}/permission-catalog", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true)] + async fn permission_catalog( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + + #[post("/api/w/{workspace_id}/permissions/validate", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn validate_permissions( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: PermissionValidationRequest, + ) -> Result; +} + +/// Deterministic standalone contract. Its source identity hashes the normalized +/// schema, not a deployment URL or another API's unrelated route inventory. +pub fn permission_contract_openapi() +-> Result { + use sha2::{Digest, Sha256}; + let document = |source_digest: &str| { + feature_permissions_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Feature permission declaration contract", + version: env!("CARGO_PKG_VERSION"), + source_digest, + }) + }; + let normalized = + document("sha256:0000000000000000000000000000000000000000000000000000000000000000")? + .to_json()?; + let digest: String = Sha256::digest(normalized.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + document(&format!("sha256:{digest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use feature_permissions::*; + + /// Wire contract + actual resolver, not a mounted HTTP authorization test. + #[test] + fn management_wire_preserves_tagged_assignments_and_explicit_children_into_resolution() { + let catalog: PermissionCatalog = serde_json::from_str(include_str!( + "../../feature-permissions/tests/fixtures/catalog.json" + )) + .unwrap(); + let mut host = PermissionHost::new(catalog.workspace.clone()); + for namespace in catalog.namespaces { + let owner = host.register_feature(namespace.feature).unwrap(); + let handle = host.bind_namespace(&owner, namespace.namespace).unwrap(); + host.declare(&handle, namespace.declaration).unwrap(); + } + let assignment: PermissionAssignment = serde_json::from_str(include_str!( + "../../feature-permissions/tests/fixtures/assignment.json" + )) + .unwrap(); + let request = PermissionValidationRequest(ValidatePermissionsRequest { assignment }); + let request: PermissionValidationRequest = + serde_json::from_slice(&serde_json::to_vec(&request).unwrap()).unwrap(); + let result = host.resolve(Some(&request.0.assignment)); + let response: PermissionResolution = + serde_json::from_slice(&serde_json::to_vec(result.projection()).unwrap()).unwrap(); + assert!( + response + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.fuga.finish").unwrap()) + ); + assert!( + !response + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.unlinked").unwrap()) + ); + assert!( + serde_json::from_value::( + serde_json::json!({"assignment":request.0.assignment, "workspace":"other"}) + ) + .is_err() + ); + } + + #[test] + fn generated_management_contract_publishes_payload_free_schema_and_context_bound_routes() { + let document = permission_contract_openapi().unwrap(); + let document: serde_json::Value = + serde_json::from_str(&document.to_json().unwrap()).unwrap(); + let checked_in: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../openapi/feature-permissions-api.json"), + ) + .expect("run generate_feature_permissions_openapi"), + ) + .unwrap(); + assert_eq!(document, checked_in); + let paths = document["paths"].as_object().unwrap(); + assert!(paths.contains_key("/api/w/{workspace_id}/permission-catalog")); + assert!(paths.contains_key("/api/w/{workspace_id}/permissions/validate")); + let schema = + serde_json::to_value(schemars::schema_for!(ValidatePermissionsRequest)).unwrap(); + let serialized = serde_json::to_string(&schema).unwrap(); + assert!(serialized.contains("namespace")); + assert!(serialized.contains("set")); + assert!(!serialized.contains("payload")); + assert_eq!(schema["additionalProperties"], false); + } +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 3577ccf8e..01a9f9988 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,6 +8,7 @@ pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod authorization; mod chat; +pub mod feature_permissions; pub use chat::*; mod subject_body; pub use subject_body::*; diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 0f03a3c42..6cd814fb7 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -41,6 +41,8 @@ use background::{ FeatureBackgroundTaskRegistryBuilder, }; +pub mod permissions; + /// Stable source-qualified identifier for a feature module. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct FeatureId(String); @@ -718,6 +720,16 @@ impl FeatureDescriptor { /// Feature module contribution boundary. pub trait FeatureModule: Send + Sync { fn descriptor(&self) -> FeatureDescriptor; + + /// Declare operation keys through the Host-bound owner registrar. This does + /// not activate the Feature or grant permissions to any execution subject. + fn declare_permissions( + &self, + _registrar: &mut permissions::FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>; } @@ -2114,6 +2126,15 @@ impl FeatureRegistryBuilder { .collect() } + /// Build a declaration catalog for the authenticated Workspace, independently + /// of Worker policy and activation. Only trusted selected modules run here. + pub fn permission_registry( + &self, + workspace: feature_permissions::WorkspaceId, + ) -> Result { + permissions::FeaturePermissionRegistry::from_builder(self, workspace) + } + pub fn plan(&self) -> Result { build_feature_install_plan(&self.descriptors()) } diff --git a/crates/worker/src/feature/permissions.rs b/crates/worker/src/feature/permissions.rs new file mode 100644 index 000000000..ec9bdbd58 --- /dev/null +++ b/crates/worker/src/feature/permissions.rs @@ -0,0 +1,148 @@ +//! Declaration-only bridge from the selected, trusted Feature modules to the +//! payload-free permission catalog. Actual policy adoption and operation checks +//! belong to the Workspace authority, not Feature activation. +//! +//! The registry builder obtains each owner from the registered module descriptor. +//! A module receives only its registrar; neither wire data nor a module-selected +//! owner string can change that registrar's registration identity. + +use super::{FeatureId, FeatureInstallError, FeatureModule, FeatureRegistryBuilder}; +use feature_permissions::{ + FeatureHandle, FeatureIdentity, NamespaceDeclaration, NamespaceHandle, NamespaceId, + PermissionHost, WorkspaceId, +}; +use std::collections::BTreeMap; + +pub struct FeaturePermissionRegistrar<'a> { + host: &'a mut PermissionHost, + feature: FeatureHandle, +} + +impl FeaturePermissionRegistrar<'_> { + /// The returned handle may be retained by the Feature for exact-key queries. + /// It is not a grant and cannot query another registered namespace. + pub fn namespace( + &mut self, + namespace: NamespaceId, + ) -> Result { + self.host + .bind_namespace(&self.feature, namespace) + .map_err(invalid) + } + + pub fn declare( + &mut self, + namespace: &NamespaceHandle, + declaration: NamespaceDeclaration, + ) -> Result<(), FeatureInstallError> { + if !namespace.is_owned_by(&self.feature) { + return Err(invalid( + "namespace handle belongs to another Feature registration", + )); + } + self.host.declare(namespace, declaration).map_err(invalid) + } +} + +pub struct FeaturePermissionRegistry { + host: PermissionHost, + features: BTreeMap, +} + +fn invalid(error: impl std::fmt::Display) -> FeatureInstallError { + FeatureInstallError::InvalidDescriptor(error.to_string()) +} + +impl FeaturePermissionRegistry { + /// Called by the trusted Host after selecting builtin/trusted Plugin modules. + /// Registration alone grants nothing to a Subject or Worker. A failed + /// declaration drops the whole candidate registry, never a partial catalog. + pub(super) fn from_builder( + builder: &FeatureRegistryBuilder, + workspace: WorkspaceId, + ) -> Result { + builder.plan().map_err(invalid)?; + let mut result = Self { + host: PermissionHost::new(workspace), + features: BTreeMap::new(), + }; + for module in &builder.modules { + result.register(module.as_ref())?; + } + Ok(result) + } + + fn register(&mut self, module: &dyn FeatureModule) -> Result<(), FeatureInstallError> { + let id = module.descriptor().id; + let identity = FeatureIdentity::parse(id.as_str()).map_err(invalid)?; + let feature = self.host.register_feature(identity).map_err(invalid)?; + module.declare_permissions(&mut FeaturePermissionRegistrar { + host: &mut self.host, + feature: feature.clone(), + })?; + self.features.insert(id, feature); + Ok(()) + } + + pub fn host(&self) -> &PermissionHost { + &self.host + } + + /// Replace a declaration only through its already bound namespace handle. + /// Failed updates retain the prior catalog and its usable resolutions. + pub fn update( + &mut self, + namespace: &NamespaceHandle, + declaration: NamespaceDeclaration, + ) -> Result<(), FeatureInstallError> { + self.host.declare(namespace, declaration).map_err(invalid) + } + + /// The trusted loader owns enable/disable, not a Feature's self-report. + pub fn set_enabled( + &mut self, + feature: &FeatureId, + enabled: bool, + ) -> Result<(), FeatureInstallError> { + let handle = self + .features + .get(feature) + .ok_or_else(|| invalid("Feature is not registered"))?; + self.host.set_enabled(handle, enabled).map_err(invalid) + } + + pub fn remove(&mut self, feature: &FeatureId) -> Result<(), FeatureInstallError> { + let handle = self + .features + .get(feature) + .ok_or_else(|| invalid("Feature is not registered"))?; + self.host.remove_feature(handle).map_err(invalid)?; + self.features.remove(feature); + Ok(()) + } + + /// A newly trusted Plugin can contribute without copying grants to existing + /// subjects. The Host selects this module; guest declaration data does not. + pub fn add_module(&mut self, module: &dyn FeatureModule) -> Result<(), FeatureInstallError> { + // Construct on a fresh host would retire all prior handles. Instead stage + // this registration, removing it on failure while preserving existing ones. + let id = module.descriptor().id; + if self.features.contains_key(&id) { + return Err(invalid("Feature is already registered")); + } + let identity = FeatureIdentity::parse(id.as_str()).map_err(invalid)?; + let feature = self.host.register_feature(identity).map_err(invalid)?; + if let Err(error) = module.declare_permissions(&mut FeaturePermissionRegistrar { + host: &mut self.host, + feature: feature.clone(), + }) { + self.host.remove_feature(&feature).map_err(invalid)?; + return Err(error); + } + self.features.insert(id, feature); + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/worker/src/feature/permissions/tests.rs b/crates/worker/src/feature/permissions/tests.rs new file mode 100644 index 000000000..442b9e08e --- /dev/null +++ b/crates/worker/src/feature/permissions/tests.rs @@ -0,0 +1,263 @@ +//! Declaration bridge + real LocalWorkdirSession/provider constraints. No live +//! Server/Runtime, policy persistence or production operation-authority hookup. +use super::*; +use crate::feature::{FeatureDescriptor, FeatureInstallContext, FeatureRuntimeKind}; +use feature_permissions::*; +use std::sync::{Arc, Mutex}; + +struct DeclaredFeature { + descriptor: FeatureDescriptor, + namespace: &'static str, + key: &'static str, + handle: Arc>>, +} +impl FeatureModule for DeclaredFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn declare_permissions( + &self, + registrar: &mut FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + let handle = registrar.namespace(NamespaceId::parse(self.namespace).unwrap())?; + registrar.declare( + &handle, + NamespaceDeclaration { + permissions: vec![PermissionDefinition { + key: PermissionKey::parse(self.key).unwrap(), + description: "Fixture operation".into(), + children: vec![], + }], + sets: vec![SetContribution { + set: PermissionSetId::parse("companion").unwrap(), + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse(self.namespace).unwrap(), + keys: vec![PermissionKey::parse(self.key).unwrap()], + })], + }], + }, + )?; + *self.handle.lock().unwrap() = Some(handle); + Ok(()) + } +} +fn feature(namespace: &'static str, key: &'static str, plugin: bool) -> DeclaredFeature { + let mut descriptor = FeatureDescriptor::builtin(namespace, namespace); + if plugin { + descriptor.id = FeatureId::new(format!("plugin:{namespace}")).unwrap(); + descriptor.runtime = FeatureRuntimeKind::ExternalPlugin; + } + DeclaredFeature { + descriptor, + namespace, + key, + handle: Arc::new(Mutex::new(None)), + } +} +fn policy() -> PermissionAssignment { + PermissionAssignment { + grants: vec![PermissionReference::Set( + PermissionSetId::parse("companion").unwrap(), + )], + ..Default::default() + } +} + +#[test] +fn registered_modules_receive_owner_bound_handles_and_trusted_plugin_updates_existing_set_holder() { + let ticket = feature("ticket", "ticket.hoge.fuga", false); + let ticket_handle = ticket.handle.clone(); + let workdir = feature("workdir", "workdir.read", false); + let mut registry = FeatureRegistryBuilder::new() + .with_module(ticket) + .with_module(workdir) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let assignment = policy(); + assert_eq!( + registry + .host() + .resolve(Some(&assignment)) + .projection() + .allowed + .len(), + 2 + ); + let plugin = feature("external", "external.read.deep", true); + let plugin_handle = plugin.handle.clone(); + registry.add_module(&plugin).unwrap(); + let resolved = registry.host().resolve(Some(&assignment)); + assert_eq!(resolved.projection().allowed.len(), 3); + let ticket_handle = ticket_handle.lock().unwrap().clone().unwrap(); + let plugin_handle = plugin_handle.lock().unwrap().clone().unwrap(); + assert!( + registry + .host() + .allows( + &ticket_handle, + &resolved, + &PermissionKey::parse("ticket.hoge.fuga").unwrap() + ) + .unwrap() + ); + assert_eq!( + registry.host().allows( + &plugin_handle, + &resolved, + &PermissionKey::parse("ticket.hoge.fuga").unwrap() + ), + Err(PermissionError::Ownership( + PermissionKey::parse("ticket.hoge.fuga").unwrap() + )) + ); + registry.set_enabled(&plugin.descriptor.id, false).unwrap(); + let resolved = registry.host().resolve(Some(&assignment)); + assert_eq!(resolved.projection().allowed.len(), 2); + assert!( + !registry + .host() + .allows( + &plugin_handle, + &resolved, + &PermissionKey::parse("external.read.deep").unwrap() + ) + .unwrap() + ); + registry.remove(&plugin.descriptor.id).unwrap(); + assert_eq!( + registry + .host() + .resolve(Some(&assignment)) + .projection() + .allowed + .len(), + 2 + ); +} + +#[test] +fn namespace_collision_rejects_the_entire_candidate_without_replacing_the_original_feature() { + let mut registry = FeatureRegistryBuilder::new() + .with_module(feature("ticket", "ticket.read", false)) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let mut plugin = feature("ticket", "ticket.steal", true); + plugin.descriptor.id = FeatureId::new("plugin:impostor").unwrap(); + assert!(registry.add_module(&plugin).is_err()); + let catalog = registry.host().catalog(); + assert_eq!(catalog.namespaces.len(), 1); + assert_eq!(catalog.namespaces[0].feature.as_str(), "builtin:ticket"); + assert_eq!( + catalog.namespaces[0].declaration.permissions[0] + .key + .as_str(), + "ticket.read" + ); +} + +#[tokio::test] +async fn allowed_operation_key_does_not_override_real_provider_scope_recursion_or_symlink_checks() { + use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; + use workdir::{LocalWorkdirSession, ReadRequest, WorkdirPath, WorkdirSession}; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("checkout"); + std::fs::create_dir_all(root.join("nested")).unwrap(); + std::fs::write(root.join("allowed.txt"), "allowed").unwrap(); + std::fs::write(root.join("nested/deep.txt"), "outside recursive scope").unwrap(); + std::fs::write(temp.path().join("secret.txt"), "outside root").unwrap(); + let scope = Scope::from_config(&ScopeConfig { + allow: vec![ScopeRule { + target: root.clone(), + permission: Permission::Read, + recursive: false, + symlink_policy: Default::default(), + }], + deny: vec![], + }) + .unwrap(); + let session = LocalWorkdirSession::new(scope, root.clone()); + let module = feature("workdir", "workdir.read", false); + let handle = module.handle.clone(); + let registry = FeatureRegistryBuilder::new() + .with_module(module) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let resolved = registry.host().resolve(Some(&policy())); + assert!( + registry + .host() + .allows( + handle.lock().unwrap().as_ref().unwrap(), + &resolved, + &PermissionKey::parse("workdir.read").unwrap() + ) + .unwrap() + ); + let read = |path| ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 20, + max_bytes: 1024, + }; + assert_eq!( + session.read(read("allowed.txt")).await.unwrap().bytes, + b"allowed" + ); + assert_eq!( + session + .read(read("nested/deep.txt")) + .await + .unwrap_err() + .denial_reason(), + Some(workdir::WorkdirDenialReason::PathOutOfScope) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(temp.path().join("secret.txt"), root.join("escape.txt")) + .unwrap(); + assert_eq!( + session + .read(read("escape.txt")) + .await + .unwrap_err() + .denial_reason(), + Some(workdir::WorkdirDenialReason::SymlinkTargetOutOfScope) + ); + } +} + +#[test] +fn registrar_rejects_a_foreign_handle_even_when_its_wire_keys_match_that_namespace() { + struct ForeignHandleFeature(NamespaceHandle); + impl FeatureModule for ForeignHandleFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin("impostor", "Impostor"); + descriptor.id = FeatureId::new("plugin:impostor").unwrap(); + descriptor.runtime = FeatureRuntimeKind::ExternalPlugin; + descriptor + } + fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn declare_permissions( + &self, + registrar: &mut FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + let declaration = serde_json::from_str(r#"{"permissions":[{"key":"ticket.steal","description":"forged","children":[]}],"sets":[]}"#).unwrap(); + registrar.declare(&self.0, declaration) + } + } + let module = feature("ticket", "ticket.read", false); + let handle = module.handle.clone(); + let mut registry = FeatureRegistryBuilder::new() + .with_module(module) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let before = registry.host().catalog(); + let impostor = ForeignHandleFeature(handle.lock().unwrap().clone().unwrap()); + assert!(registry.add_module(&impostor).is_err()); + assert_eq!(before, registry.host().catalog()); +} diff --git a/openapi/feature-permissions-api.json b/openapi/feature-permissions-api.json new file mode 100644 index 000000000..64af1c82f --- /dev/null +++ b/openapi/feature-permissions-api.json @@ -0,0 +1,653 @@ +{ + "components": { + "schemas": { + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "NamespaceCatalog": { + "additionalProperties": false, + "properties": { + "declaration": { + "$ref": "#/components/schemas/NamespaceDeclaration" + }, + "enabled": { + "type": "boolean" + }, + "feature": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "feature", + "enabled", + "declaration" + ], + "type": "object" + }, + "NamespaceDeclaration": { + "additionalProperties": false, + "description": "This input deliberately has no owner field. The Host supplies the registered\nidentity/namespace handle independently of guest-controlled declaration data.", + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/PermissionDefinition" + }, + "type": "array" + }, + "sets": { + "items": { + "$ref": "#/components/schemas/SetContribution" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "sets" + ], + "type": "object" + }, + "NamespacePermissions": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "keys" + ], + "type": "object" + }, + "OriginStep": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "key", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionAssignment": { + "additionalProperties": false, + "description": "`None` at the consuming policy boundary means unset. An empty assignment is\nexplicit deny-all; this crate never chooses or reinstates a default.", + "properties": { + "exclusions": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "grants": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "grants", + "exclusions", + "revoked" + ], + "type": "object" + }, + "PermissionCatalog": { + "additionalProperties": false, + "properties": { + "namespaces": { + "items": { + "$ref": "#/components/schemas/NamespaceCatalog" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "namespaces" + ], + "type": "object" + }, + "PermissionDefinition": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "key", + "description", + "children" + ], + "type": "object" + }, + "PermissionDiagnostic": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unset", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "revoked", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_namespace", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_key", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "disabled_feature", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "excluded", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionReference": { + "description": "The discriminant, not an identifier's spelling, selects a reference kind.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "namespace", + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/NamespacePermissions" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionResolution": { + "additionalProperties": false, + "description": "A management projection, not an executable credential. Only the Host's opaque\nresolution can be queried through a namespace handle.", + "properties": { + "allowed": { + "additionalProperties": { + "items": { + "items": { + "$ref": "#/components/schemas/OriginStep" + }, + "type": "array" + }, + "type": "array" + }, + "type": "object" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/PermissionDiagnostic" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "allowed", + "diagnostics" + ], + "type": "object" + }, + "RepositoryApiError": { + "additionalProperties": false, + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "SetContribution": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "set": { + "type": "string" + } + }, + "required": [ + "set", + "children" + ], + "type": "object" + }, + "ValidatePermissionsRequest": { + "additionalProperties": false, + "description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", + "properties": { + "assignment": { + "$ref": "#/components/schemas/PermissionAssignment" + } + }, + "required": [ + "assignment" + ], + "type": "object" + }, + "string": { + "type": "string" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Feature permission declaration contract", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" + }, + "openapi": "3.1.0", + "paths": { + "/api/w/{workspace_id}/permission-catalog": { + "get": { + "operationId": "permission_catalog", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionCatalog" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/permissions/validate": { + "post": { + "operationId": "validate_permissions", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePermissionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionResolution" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + } + } +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 8743b3819..caa1e9467 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/generated/feature-permissions.generated.ts b/web/workspace/src/lib/generated/feature-permissions.generated.ts new file mode 100644 index 000000000..f61da4790 --- /dev/null +++ b/web/workspace/src/lib/generated/feature-permissions.generated.ts @@ -0,0 +1,18 @@ +// Generated by feature-permissions; do not edit. +export type WorkspaceId = string; +export type FeatureIdentity = string; +export type PermissionSetId = string; +export type NamespaceId = string; +export type PermissionKey = string; +export type NamespacePermissions = { namespace: NamespaceId, keys: Array, }; +export type PermissionReference = { "kind": "set", "value": PermissionSetId } | { "kind": "namespace", "value": NamespacePermissions }; +export type PermissionAssignment = { grants: Array, exclusions: Array, revoked: boolean, }; +export type PermissionDefinition = { key: PermissionKey, description: string, children: Array, }; +export type SetContribution = { set: PermissionSetId, children: Array, }; +export type NamespaceDeclaration = { permissions: Array, sets: Array, }; +export type NamespaceCatalog = { namespace: NamespaceId, feature: FeatureIdentity, enabled: boolean, declaration: NamespaceDeclaration, }; +export type PermissionCatalog = { workspace: WorkspaceId, namespaces: Array, }; +export type OriginStep = { "kind": "set", "value": PermissionSetId } | { "kind": "feature", "value": FeatureIdentity } | { "kind": "key", "value": PermissionKey }; +export type PermissionDiagnostic = { "kind": "unset" } | { "kind": "revoked" } | { "kind": "unknown_set", "value": PermissionSetId } | { "kind": "unknown_namespace", "value": NamespaceId } | { "kind": "unknown_key", "value": PermissionKey } | { "kind": "disabled_feature", "value": FeatureIdentity } | { "kind": "excluded", "value": PermissionKey }; +export type PermissionResolution = { workspace: WorkspaceId, allowed: { [key in PermissionKey]: Array> }, diagnostics: Array, }; +export type ValidatePermissionsRequest = { assignment: PermissionAssignment, }; diff --git a/web/workspace/src/lib/workspace/feature-permissions.test.ts b/web/workspace/src/lib/workspace/feature-permissions.test.ts new file mode 100644 index 000000000..61534c74b --- /dev/null +++ b/web/workspace/src/lib/workspace/feature-permissions.test.ts @@ -0,0 +1,80 @@ +import assignmentFixture from "../../../../../crates/feature-permissions/tests/fixtures/assignment.json" with { + type: "json", +}; +import catalogFixture from "../../../../../crates/feature-permissions/tests/fixtures/catalog.json" with { + type: "json", +}; +import type { + NamespaceCatalog, + PermissionAssignment, + PermissionReference, +} from "../generated/feature-permissions.generated.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +// Rustが同じfixtureを本番Serdeで検証する。ここではWebが利用する公開形と +// children/多段キーの保持だけを確認し、認可や入力validatorの代用にしない。 +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +Deno.test("shared assignment fixture preserves Set/Namespace tags and full keys in the Web wire shape", () => { + const expected: PermissionAssignment = { + grants: [ + { kind: "set", value: "ticket" }, + { + kind: "namespace", + value: { + namespace: "ticket", + keys: ["ticket.hoge", "ticket.hoge.fuga.finish"], + }, + }, + ], + exclusions: [{ + kind: "namespace", + value: { namespace: "ticket", keys: ["ticket.hoge.unlinked"] }, + }], + revoked: false, + }; + assertEquals(assignmentFixture, expected); + assertEquals(JSON.parse(JSON.stringify(assignmentFixture)), expected); +}); + +Deno.test("shared NamespaceCatalog fixture preserves multilevel children without deriving prefix edges", () => { + const namespace = catalogFixture.namespaces[0]!; + assertEquals(namespace.namespace, "ticket"); + assertEquals(namespace.feature, "builtin:ticket"); + assertEquals(namespace.enabled, true); + type CatalogPermission = + NamespaceCatalog["declaration"]["permissions"][number]; + const expectedEdges: Pick[] = [ + { key: "ticket.hoge", children: ["ticket.hoge.fuga"] }, + { key: "ticket.hoge.fuga", children: ["ticket.hoge.fuga.finish"] }, + { key: "ticket.hoge.fuga.finish", children: [] }, + { key: "ticket.hoge.unlinked", children: [] }, + ]; + assertEquals( + namespace.declaration.permissions.map(({ key, children }) => ({ + key, + children, + })), + expectedEdges, + ); + const expectedChildren: PermissionReference[] = [{ + kind: "namespace", + value: { namespace: "ticket", keys: ["ticket.hoge"] }, + }]; + assertEquals(namespace.declaration.sets, [{ + set: "ticket", + children: expectedChildren, + }]); + assertEquals(JSON.parse(JSON.stringify(namespace)), namespace); +}); From e166117c607a43c36a99062e4c677159dd200d94 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 11:28:44 +0900 Subject: [PATCH 6/7] Exercise permission catalog and resolution management DTO roundtrips --- crates/server-api/src/feature_permissions.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/server-api/src/feature_permissions.rs b/crates/server-api/src/feature_permissions.rs index 48fbb8df4..41105e24d 100644 --- a/crates/server-api/src/feature_permissions.rs +++ b/crates/server-api/src/feature_permissions.rs @@ -78,6 +78,11 @@ mod tests { "../../feature-permissions/tests/fixtures/catalog.json" )) .unwrap(); + let catalog: PermissionCatalogResponse = serde_json::from_slice( + &serde_json::to_vec(&PermissionCatalogResponse(catalog)).unwrap(), + ) + .unwrap(); + let catalog = catalog.0; let mut host = PermissionHost::new(catalog.workspace.clone()); for namespace in catalog.namespaces { let owner = host.register_feature(namespace.feature).unwrap(); @@ -92,8 +97,12 @@ mod tests { let request: PermissionValidationRequest = serde_json::from_slice(&serde_json::to_vec(&request).unwrap()).unwrap(); let result = host.resolve(Some(&request.0.assignment)); - let response: PermissionResolution = - serde_json::from_slice(&serde_json::to_vec(result.projection()).unwrap()).unwrap(); + let response: PermissionResolutionResponse = serde_json::from_slice( + &serde_json::to_vec(&PermissionResolutionResponse(result.projection().clone())) + .unwrap(), + ) + .unwrap(); + let response = response.0; assert!( response .allowed From d1cd2855e8227bd818b0c53babef69002f989a4f Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 4 Nov 2026 11:47:45 +0900 Subject: [PATCH 7/7] Preserve explicit permission exclusions through disabled Feature declarations --- crates/feature-permissions/README.md | 8 +- crates/feature-permissions/src/registry.rs | 26 +++- .../feature-permissions/tests/resolution.rs | 122 ++++++++++++++++++ 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/crates/feature-permissions/README.md b/crates/feature-permissions/README.md index 700545288..c6816a4bb 100644 --- a/crates/feature-permissions/README.md +++ b/crates/feature-permissions/README.md @@ -19,9 +19,11 @@ the same references. Installing a declaration validates its candidate graph atomically. Duplicate definitions, unknown references and cycles reject the update without changing the prior catalog. Duplicate assignment paths are deduplicated, with distinct provenance paths retained in deterministic order. Invalid assignment -references deny the whole assignment with diagnostics. Disabled contributions are -omitted independently; removing a Feature retires its handles and withdraws only -its declarations. Dangling references deny rather than recovering an old result. +references deny the whole assignment with diagnostics. Disabled positive +contributions are omitted independently. Explicit exclusions still traverse disabled +Features' declared keys and set edges, so disabling a Plugin cannot revive another +Feature's excluded operation. Removing a Feature retires its handles and withdraws +only its declarations. Dangling references deny rather than recovering an old result. Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the current assignment before use. A resolution retains its actual catalog snapshot; diff --git a/crates/feature-permissions/src/registry.rs b/crates/feature-permissions/src/registry.rs index af9cb8d3e..14508e30d 100644 --- a/crates/feature-permissions/src/registry.rs +++ b/crates/feature-permissions/src/registry.rs @@ -317,11 +317,11 @@ impl PermissionHost { None => projection.diagnostics.push(PermissionDiagnostic::Unset), Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked), Some(a) => { - let mut expansion = Expansion::new(self); + let mut expansion = Expansion::new(self, ExpansionPurpose::Grant); for reference in &a.grants { expansion.reference(reference, vec![]); } - let mut exclusions = Expansion::new(self); + let mut exclusions = Expansion::new(self, ExpansionPurpose::Exclusion); for reference in &a.exclusions { exclusions.reference(reference, vec![]); } @@ -391,7 +391,7 @@ impl PermissionHost { requested: &[PermissionKey], ) -> Result { self.check_resolution(ceiling)?; - let mut expansion = Expansion::new(self); + let mut expansion = Expansion::new(self, ExpansionPurpose::Grant); for key in requested { expansion.key(key, vec![]); } @@ -572,15 +572,25 @@ fn validate_graph( Ok(()) } +#[derive(PartialEq, Eq)] +enum ExpansionPurpose { + Grant, + // Disabling an owner withdraws positive contributions, not the meaning of + // an explicit deny. Its retained declaration still defines exclusion edges. + Exclusion, +} + struct Expansion<'a> { host: &'a PermissionHost, + purpose: ExpansionPurpose, allowed: BTreeMap>>, diagnostics: BTreeSet, } impl<'a> Expansion<'a> { - fn new(host: &'a PermissionHost) -> Self { + fn new(host: &'a PermissionHost, purpose: ExpansionPurpose) -> Self { Self { host, + purpose, allowed: BTreeMap::new(), diagnostics: BTreeSet::new(), } @@ -620,7 +630,9 @@ impl<'a> Expansion<'a> { .insert(PermissionDiagnostic::DisabledFeature( record.owner.clone(), )); - continue; + if self.purpose == ExpansionPurpose::Grant { + continue; + } } let mut next = path.clone(); next.push(OriginStep::Set(set.clone())); @@ -656,7 +668,9 @@ impl<'a> Expansion<'a> { if !self.host.catalog.features[&record.owner].enabled { self.diagnostics .insert(PermissionDiagnostic::DisabledFeature(record.owner.clone())); - return; + if self.purpose == ExpansionPurpose::Grant { + return; + } } if path.contains(&OriginStep::Key(key.clone())) { return; diff --git a/crates/feature-permissions/tests/resolution.rs b/crates/feature-permissions/tests/resolution.rs index 668d73666..320492f90 100644 --- a/crates/feature-permissions/tests/resolution.rs +++ b/crates/feature-permissions/tests/resolution.rs @@ -910,3 +910,125 @@ fn current_parent_execution_delegation_and_explicit_revocation_each_limit_childr PermissionError::RevokedGrant ); } + +fn assert_exclusion_survives_feature_disable(exclusion: PermissionReference) { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration( + &[("ticket.read", &[]), ("ticket.keep", &[])], + &[( + "companion", + vec![keys("ticket", &["ticket.read", "ticket.keep"])], + )], + ), + ) + .unwrap(); + let (plugin, bundle) = register(&mut host, "plugin:bundle", "bundle"); + host.declare( + &bundle, + declaration( + &[ + ("bundle.block", &["ticket.read"]), + ("bundle.block.nested", &["bundle.block"]), + ("bundle.query", &[]), + ], + &[ + ("companion", vec![keys("bundle", &["bundle.query"])]), + ("blocked", vec![keys("bundle", &["bundle.block.nested"])]), + ("blocked-alias", vec![set("blocked")]), + ], + ), + ) + .unwrap(); + let unrestricted_policy = assignment(vec![set("companion")]); + let restricted_policy = PermissionAssignment { + exclusions: vec![exclusion], + ..unrestricted_policy.clone() + }; + // Issue before the deny is added, so a frozen child/Job cannot evade it. + let initial = host.resolve(Some(&unrestricted_policy)); + let requested = [key("ticket.read"), key("ticket.keep")]; + let child = host.issue_delegation(&initial, &requested).unwrap(); + let job = host + .issue_job_grant("job-a".into(), &initial, &requested) + .unwrap(); + + for enabled in [true, false, true] { + host.set_enabled(&plugin, enabled).unwrap(); + let unrestricted = host.resolve(Some(&unrestricted_policy)); + let restricted = host.resolve(Some(&restricted_policy)); + assert!( + !host + .allows(&ticket, &restricted, &key("ticket.read")) + .unwrap() + ); + assert!( + host.allows(&ticket, &restricted, &key("ticket.keep")) + .unwrap() + ); + assert_eq!( + host.allows(&bundle, &restricted, &key("bundle.query")) + .unwrap(), + enabled, + "disable must still withdraw the Plugin's positive contribution" + ); + assert!( + restricted + .projection() + .diagnostics + .contains(&PermissionDiagnostic::Excluded(key("ticket.read"))) + ); + assert_eq!( + restricted + .projection() + .diagnostics + .contains(&PermissionDiagnostic::DisabledFeature( + FeatureIdentity::parse("plugin:bundle").unwrap() + )), + !enabled + ); + assert_eq!( + host.issue_delegation(&restricted, &[key("ticket.read")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.read")) + ); + assert_eq!( + host.issue_job_grant("job-b".into(), &restricted, &[key("ticket.read")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.read")) + ); + for (execution, delegation) in [ + (&restricted, &unrestricted), + (&unrestricted, &restricted), + (&restricted, &restricted), + ] { + assert_keys( + &host.resolve_limited(execution, delegation, &child).unwrap(), + &["ticket.keep"], + ); + assert_keys( + &host + .resolve_job("job-a", execution, delegation, &child, &job) + .unwrap(), + &["ticket.keep"], + ); + } + } +} + +#[test] +fn disabled_feature_cannot_erase_direct_cross_feature_exclusion() { + assert_exclusion_survives_feature_disable(keys("bundle", &["bundle.block"])); +} + +#[test] +fn disabled_feature_cannot_erase_multilevel_cross_feature_exclusion() { + assert_exclusion_survives_feature_disable(keys("bundle", &["bundle.block.nested"])); +} + +#[test] +fn disabled_feature_cannot_erase_set_cross_feature_exclusion() { + assert_exclusion_survives_feature_disable(set("blocked-alias")); +}