feat: integrate Feature permission declarations with content history

This commit is contained in:
2026-11-04 13:13:48 +09:00
29 changed files with 3853 additions and 1 deletions
Generated
+17
View File
@@ -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",
+3
View File
@@ -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" }
+16
View File
@@ -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
+103
View File
@@ -0,0 +1,103 @@
# 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 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;
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.
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<dyn std::error::Error>>(())
```
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();
```
@@ -0,0 +1,3 @@
fn main() {
print!("{}", feature_permissions::typescript());
}
+10
View File
@@ -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,
};
+688
View File
@@ -0,0 +1,688 @@
//! 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
}
/// 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 {
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<RegistryState>,
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<PermissionKey>,
registrations: BTreeMap<PermissionKey, Arc<()>>,
revoked: bool,
}
impl LimitedGrant {
pub fn keys(&self) -> &BTreeSet<PermissionKey> {
&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<RegistryState>,
}
#[derive(Clone, Debug, Default)]
struct RegistryState {
features: BTreeMap<FeatureIdentity, FeatureRecord>,
namespaces: BTreeMap<NamespaceId, NamespaceRecord>,
}
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<FeatureHandle, PermissionError> {
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<NamespaceHandle, PermissionError> {
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, ExpansionPurpose::Grant);
for reference in &a.grants {
expansion.reference(reference, vec![]);
}
let mut exclusions = Expansion::new(self, ExpansionPurpose::Exclusion);
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<bool, PermissionError> {
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<LimitedGrant, PermissionError> {
self.check_resolution(ceiling)?;
let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
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<JobGrant, PermissionError> {
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<ResolvedPermissions, PermissionError> {
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<ResolvedPermissions, PermissionError> {
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<NamespaceId, NamespaceRecord>,
) -> Result<BTreeMap<Node, Vec<Node>>, 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<NamespaceId, NamespaceRecord>,
) -> Result<(), PermissionError> {
fn visit(
node: &Node,
nodes: &BTreeMap<Node, Vec<Node>>,
active: &mut BTreeSet<Node>,
done: &mut BTreeSet<Node>,
) -> 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(())
}
#[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<PermissionKey, BTreeSet<Vec<OriginStep>>>,
diagnostics: BTreeSet<PermissionDiagnostic>,
}
impl<'a> Expansion<'a> {
fn new(host: &'a PermissionHost, purpose: ExpansionPurpose) -> Self {
Self {
host,
purpose,
allowed: BTreeMap::new(),
diagnostics: BTreeSet::new(),
}
}
fn reference(&mut self, reference: &PermissionReference, path: Vec<OriginStep>) {
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(),
));
if self.purpose == ExpansionPurpose::Grant {
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<OriginStep>) {
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()));
if self.purpose == ExpansionPurpose::Grant {
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());
}
}
}
+218
View File
@@ -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<String>) -> Result<Self, String> {
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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<PermissionKey>,
}
/// 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<PermissionReference>,
pub exclusions: Vec<PermissionReference>,
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<PermissionKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetContribution {
pub set: PermissionSetId,
pub children: Vec<PermissionReference>,
}
/// 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<PermissionDefinition>,
pub sets: Vec<SetContribution>,
}
#[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<NamespaceCatalog>,
}
#[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<PermissionKey, Vec<Vec<OriginStep>>>,
pub diagnostics: Vec<PermissionDiagnostic>,
}
/// 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::<Vec<_>>()
.join("\n")
)
}
@@ -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
}
@@ -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"]
+45
View File
@@ -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"] }
}
]
}
]
}
}
]
}
+35
View File
@@ -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"]
@@ -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());
}
File diff suppressed because it is too large Load Diff
@@ -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<T>(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::<Value>(json).unwrap(),
"serialization must not drop or synthesize wire fields"
);
assert_eq!(
serde_json::from_str::<T>(&serde_json::to_string(&from_toml).unwrap()).unwrap(),
from_json
);
assert_eq!(
toml::from_str::<T>(&toml::to_string(&from_json).unwrap()).unwrap(),
from_toml
);
from_json
}
fn reject_in_both_formats<T: DeserializeOwned>(rule: &str, input: Value) {
assert!(
serde_json::from_value::<T>(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::<T>(&text).is_err(),
"TOML accepted {rule}: {text}"
);
}
fn reject_authority_and_payload_fields<T: DeserializeOwned>(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::<T>(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::<Vec<_>>(),
["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::<Vec<_>>(),
)
})
.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::<PermissionAssignment>(
&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::<PermissionAssignment>(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::<PermissionReference>(rule, input);
}
}
#[test]
fn external_identifiers_reject_invalid_segments_without_truncation() {
for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] {
reject_in_both_formats::<PermissionReference>(
"invalid set identifier",
json!({"kind": "set", "value": invalid}),
);
reject_in_both_formats::<NamespacePermissions>(
"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::<NamespacePermissions>(
"invalid assigned key",
json!({"namespace": "ticket", "keys": [invalid]}),
);
reject_in_both_formats::<PermissionDefinition>(
"invalid declared key",
json!({"key": invalid, "description": "", "children": []}),
);
reject_in_both_formats::<PermissionDefinition>(
"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::<PermissionCatalog>("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::<PermissionAssignment>(assignment.clone());
for reference in assignment["grants"].as_array().unwrap() {
reject_authority_and_payload_fields::<PermissionReference>(reference.clone());
}
reject_authority_and_payload_fields::<NamespacePermissions>(
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::<ValidatePermissionsRequest>(request.clone());
let mut nested = request;
nested["assignment"]["workspace"] = json!("other-workspace");
reject_in_both_formats::<ValidatePermissionsRequest>("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::<PermissionAssignment>(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::<NamespaceDeclaration>(declaration.clone());
reject_authority_and_payload_fields::<PermissionDefinition>(
declaration["permissions"][0].clone(),
);
reject_authority_and_payload_fields::<SetContribution>(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::<NamespaceDeclaration>("feature identity injection", input);
}
+1
View File
@@ -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
@@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
print!(
"{}",
server_api::feature_permissions::permission_contract_openapi()?.to_json()?
);
Ok(())
}
@@ -0,0 +1,149 @@
//! 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<PermissionCatalogResponse, RepositoryApiError>;
#[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<PermissionResolutionResponse, RepositoryApiError>;
}
/// 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<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
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 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();
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: PermissionResolutionResponse = serde_json::from_slice(
&serde_json::to_vec(&PermissionResolutionResponse(result.projection().clone()))
.unwrap(),
)
.unwrap();
let response = response.0;
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::<ValidatePermissionsRequest>(
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);
}
}
+1
View File
@@ -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::*;
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
error-contract.workspace = true
base64.workspace = true
agen = { workspace = true }
fs4.workspace = true
+1
View File
@@ -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
+2
View File
@@ -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 }
+21
View File
@@ -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, FeatureInstallError> {
permissions::FeaturePermissionRegistry::from_builder(self, workspace)
}
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
build_feature_install_plan(&self.descriptors())
}
+148
View File
@@ -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<NamespaceHandle, FeatureInstallError> {
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<FeatureId, FeatureHandle>,
}
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<Self, FeatureInstallError> {
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;
@@ -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<Mutex<Option<NamespaceHandle>>>,
}
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());
}
+653
View File
@@ -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": []
}
]
}
}
}
}
+1 -1
View File
@@ -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",
@@ -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<PermissionKey>, };
export type PermissionReference = { "kind": "set", "value": PermissionSetId } | { "kind": "namespace", "value": NamespacePermissions };
export type PermissionAssignment = { grants: Array<PermissionReference>, exclusions: Array<PermissionReference>, revoked: boolean, };
export type PermissionDefinition = { key: PermissionKey, description: string, children: Array<PermissionKey>, };
export type SetContribution = { set: PermissionSetId, children: Array<PermissionReference>, };
export type NamespaceDeclaration = { permissions: Array<PermissionDefinition>, sets: Array<SetContribution>, };
export type NamespaceCatalog = { namespace: NamespaceId, feature: FeatureIdentity, enabled: boolean, declaration: NamespaceDeclaration, };
export type PermissionCatalog = { workspace: WorkspaceId, namespaces: Array<NamespaceCatalog>, };
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<Array<OriginStep>> }, diagnostics: Array<PermissionDiagnostic>, };
export type ValidatePermissionsRequest = { assignment: PermissionAssignment, };
@@ -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<CatalogPermission, "key" | "children">[] = [
{ 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);
});