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"