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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "feature-permissions"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
|
"ts-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "feature-storage"
|
name = "feature-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4752,6 +4764,7 @@ name = "server-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api-macros",
|
"api-macros",
|
||||||
|
"feature-permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"merge-request",
|
"merge-request",
|
||||||
"plugin-plan",
|
"plugin-plan",
|
||||||
@@ -4797,6 +4810,7 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"error-contract",
|
||||||
"filetime",
|
"filetime",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -7301,6 +7315,8 @@ dependencies = [
|
|||||||
"client",
|
"client",
|
||||||
"config-source",
|
"config-source",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"error-contract",
|
||||||
|
"feature-permissions",
|
||||||
"flow",
|
"flow",
|
||||||
"fs-operation",
|
"fs-operation",
|
||||||
"fs4",
|
"fs4",
|
||||||
@@ -7357,6 +7373,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"decodal",
|
"decodal",
|
||||||
|
"error-contract",
|
||||||
"flow",
|
"flow",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/feature-storage",
|
"crates/feature-storage",
|
||||||
|
"crates/feature-permissions",
|
||||||
"crates/workspace-drive",
|
"crates/workspace-drive",
|
||||||
"crates/subjektiv",
|
"crates/subjektiv",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
@@ -84,6 +85,7 @@ default-members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/feature-storage",
|
"crates/feature-storage",
|
||||||
|
"crates/feature-permissions",
|
||||||
"crates/workspace-drive",
|
"crates/workspace-drive",
|
||||||
"crates/subjektiv",
|
"crates/subjektiv",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
@@ -124,6 +126,7 @@ mcp = { path = "crates/mcp" }
|
|||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
feature-storage = { path = "crates/feature-storage" }
|
feature-storage = { path = "crates/feature-storage" }
|
||||||
|
feature-permissions = { path = "crates/feature-permissions" }
|
||||||
workspace-drive = { path = "crates/workspace-drive" }
|
workspace-drive = { path = "crates/workspace-drive" }
|
||||||
subjektiv = { path = "crates/subjektiv" }
|
subjektiv = { path = "crates/subjektiv" }
|
||||||
merge-request = { path = "crates/merge-request" }
|
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"]
|
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
feature-permissions.workspace = true
|
||||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
plugin-plan.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 use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
mod chat;
|
mod chat;
|
||||||
|
pub mod feature_permissions;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
mod subject_body;
|
mod subject_body;
|
||||||
pub use subject_body::*;
|
pub use subject_body::*;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
error-contract.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
fs4.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"]
|
ws-server = ["http-server", "axum/ws", "tokio/sync"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
error-contract.workspace = true
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ autobins = false
|
|||||||
default = []
|
default = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
error-contract.workspace = true
|
||||||
|
feature-permissions.workspace = true
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
agen = { workspace = true, features = ["codex"] }
|
agen = { workspace = true, features = ["codex"] }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ use background::{
|
|||||||
FeatureBackgroundTaskRegistryBuilder,
|
FeatureBackgroundTaskRegistryBuilder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod permissions;
|
||||||
|
|
||||||
/// Stable source-qualified identifier for a feature module.
|
/// Stable source-qualified identifier for a feature module.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub struct FeatureId(String);
|
pub struct FeatureId(String);
|
||||||
@@ -718,6 +720,16 @@ impl FeatureDescriptor {
|
|||||||
/// Feature module contribution boundary.
|
/// Feature module contribution boundary.
|
||||||
pub trait FeatureModule: Send + Sync {
|
pub trait FeatureModule: Send + Sync {
|
||||||
fn descriptor(&self) -> FeatureDescriptor;
|
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>;
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2114,6 +2126,15 @@ impl FeatureRegistryBuilder {
|
|||||||
.collect()
|
.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> {
|
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
build_feature_install_plan(&self.descriptors())
|
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": "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",
|
"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",
|
"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: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: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",
|
"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