feat: integrate Feature permission declarations with content history
This commit is contained in:
Generated
+17
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
error-contract.workspace = true
|
||||
base64.workspace = true
|
||||
agen = { workspace = true }
|
||||
fs4.workspace = true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user