use std::collections::{BTreeMap, BTreeSet};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use agent_mesh_protocol::{SerdeSig, UserKey, UserPublic};
use anyhow::{ensure, Context as _};
use content_addressable::{canonical, ContentAddressable, ContentError, ContentId};
use serde::{Deserialize, Serialize};
use crate::secrets::TokenIdentity;
use crate::DenialKind;
const SCHEMA: &str = "newt.durable-grants/v1";
const MAX_STORE_BYTES: usize = 1024 * 1024;
const MAX_GRANTS: usize = 4096;
const MAX_TARGET_BYTES: usize = 4096;
const MAX_WORKSPACES: usize = 256;
pub type GrantSet = BTreeSet<(DenialKind, String)>;
#[path = "durable_grants_profiles.rs"]
mod profiles;
pub use profiles::{
commit, load_snapshot, ProfileChange, ProfileState, ReviewedWorkspaceEdit, VerifiedSnapshot,
WorkspaceEdit, WorkspaceProfile,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Payload {
schema: String,
workspaces: BTreeMap<String, GrantSet>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
profiles: BTreeMap<String, WorkspaceProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_workspace: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
profile_history: Vec<crate::event_journal::JournalLine<ProfileChange>>,
}
impl Payload {
fn empty() -> Self {
Self {
schema: SCHEMA.into(),
workspaces: BTreeMap::new(),
profiles: BTreeMap::new(),
default_workspace: None,
profile_history: Vec::new(),
}
}
fn validate(&self) -> anyhow::Result<()> {
ensure!(
self.schema == SCHEMA,
"unsupported durable permission schema"
);
ensure!(
self.workspaces.len() <= MAX_WORKSPACES,
"too many permission workspaces"
);
ensure!(
self.workspaces.values().map(GrantSet::len).sum::<usize>() <= MAX_GRANTS,
"too many durable permission grants"
);
for (workspace, grants) in &self.workspaces {
validate_target(workspace)?;
ensure!(
Path::new(workspace).is_absolute(),
"permission workspace must be absolute"
);
validate_grants(grants)?;
}
self.validate_profiles()
}
}
impl ContentAddressable for Payload {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
canonical::to_canonical_dagcbor(self)
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SignedPayload {
payload: Payload,
signature: SerdeSig,
}
impl ContentAddressable for SignedPayload {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
canonical::to_canonical_dagcbor(self)
}
}
pub struct VerifiedGrants {
grants: GrantSet,
id: ContentId,
}
impl VerifiedGrants {
pub fn grants(&self) -> &GrantSet {
&self.grants
}
pub fn content_id(&self) -> &ContentId {
&self.id
}
}
pub fn store_path(config_path: &Path) -> PathBuf {
config_path
.with_file_name("ocap")
.join("session-grants.age")
}
pub fn load(
path: &Path,
workspace: &Path,
trusted_root: &UserPublic,
encryption: &TokenIdentity,
) -> anyhow::Result<VerifiedGrants> {
let workspace = workspace_binding(workspace)?;
verified_for(read_payload(path, trusted_root, encryption)?, &workspace)
}
pub fn merge(
path: &Path,
workspace: &Path,
additions: &GrantSet,
root: &UserKey,
encryption: &TokenIdentity,
) -> anyhow::Result<VerifiedGrants> {
validate_grants(additions)?;
if additions.is_empty() {
return load(path, workspace, &root.public(), encryption);
}
let workspace = workspace_binding(workspace)?;
let destination = crate::atomic_fs::ResolvedPath::resolve(path)?;
let _lock = crate::atomic_fs::acquire_lock(&destination.lock_path())?;
let mut payload = read_payload(destination.as_path(), &root.public(), encryption)?;
payload
.workspaces
.entry(workspace.clone())
.or_default()
.extend(additions.iter().cloned());
let ciphertext = encode(&payload, root, encryption)?;
let expected = payload.content_id()?;
destination.atomic_write_private(ciphertext.as_bytes())?;
let saved = read_payload(destination.as_path(), &root.public(), encryption)?;
saved.ensure_content_id(&expected)?;
verified_for(saved, &workspace)
}
fn validate_target(target: &str) -> anyhow::Result<()> {
ensure!(
!target.trim().is_empty() && !target.contains('\0') && target.len() <= MAX_TARGET_BYTES,
"permission target must be nonempty, NUL-free and at most 4096 bytes"
);
Ok(())
}
fn validate_grants(grants: &GrantSet) -> anyhow::Result<()> {
ensure!(
grants.len() <= MAX_GRANTS,
"too many durable permission grants"
);
for (_, target) in grants {
validate_target(target)?;
}
Ok(())
}
fn workspace_binding(workspace: &Path) -> anyhow::Result<String> {
let canonical = workspace
.canonicalize()
.context("cannot resolve permission workspace")?;
ensure!(
canonical.is_dir(),
"permission workspace must be a directory"
);
let value = canonical
.to_str()
.context("permission workspace must be valid UTF-8")?;
validate_target(value)?;
Ok(value.to_string())
}
fn verified_for(payload: Payload, workspace: &str) -> anyhow::Result<VerifiedGrants> {
Ok(VerifiedGrants {
id: payload.content_id()?,
grants: payload
.workspaces
.get(workspace)
.cloned()
.unwrap_or_default(),
})
}
fn read_payload(
path: &Path,
root: &UserPublic,
encryption: &TokenIdentity,
) -> anyhow::Result<Payload> {
match std::fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Payload::empty()),
Err(error) => return Err(error.into()),
Ok(_) => {}
}
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path)?;
ensure!(
file.metadata()?.is_file(),
"permission store must be a regular file"
);
let mut bytes = Vec::new();
file.take((MAX_STORE_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
decode(&bytes, root, encryption)
}
fn encode(payload: &Payload, root: &UserKey, encryption: &TokenIdentity) -> anyhow::Result<String> {
payload.validate()?;
let canonical = payload.canonical_form()?;
ensure!(
canonical.len() <= MAX_STORE_BYTES,
"permission store exceeds byte limit"
);
let signed = SignedPayload {
payload: payload.clone(),
signature: SerdeSig(root.sign(&canonical)),
};
let plaintext = signed.canonical_form()?;
ensure!(
plaintext.len() <= MAX_STORE_BYTES,
"permission store exceeds byte limit"
);
let ciphertext =
crate::secrets::encrypt_to_identity(encryption, &plaintext).map_err(anyhow::Error::msg)?;
ensure!(
ciphertext.len() <= MAX_STORE_BYTES,
"encrypted permission store exceeds byte limit"
);
Ok(ciphertext)
}
fn decode(bytes: &[u8], root: &UserPublic, encryption: &TokenIdentity) -> anyhow::Result<Payload> {
ensure!(
bytes.len() <= MAX_STORE_BYTES,
"encrypted permission store exceeds byte limit"
);
let plaintext = crate::secrets::decrypt_with_identity(encryption, bytes)
.context("cannot decrypt durable permission store")?;
ensure!(
plaintext.len() <= MAX_STORE_BYTES,
"permission store exceeds byte limit"
);
let signed = SignedPayload::from_canonical_form(&plaintext)?;
root.verify(&signed.payload.canonical_form()?, &signed.signature.0)
.context("durable permissions do not verify under the trusted operator key")?;
signed.payload.validate()?;
Ok(signed.payload)
}
#[cfg(test)]
#[path = "durable_grants_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "durable_grants_profile_tests.rs"]
mod profile_tests;