- 1
//! Durable, append-only audience grants for shared Agent conversations. - 2
//! - 3
//! This module owns capability data only. HTTP admission is wired separately - 4
//! so an invitation cannot become usable before every shared route enforces - 5
//! the same audience decision. - 6
- 7
use serde::{Deserialize, Serialize}; - 8
use sha2::{Digest, Sha256}; - 9
use std::io::Write; - 10
use std::path::{Path, PathBuf}; - 11
use subtle::ConstantTimeEq; - 12
- 13
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 14
pub struct AudienceGrant { - 15
pub grant_id: String, - 16
pub principal_id: String, - 17
pub display_name: String, - 18
pub conversation_id: String, - 19
pub audience_id: String, - 20
pub capabilities: Vec<String>, - 21
pub token_hash: String, - 22
pub created_at: String, - 23
pub expires_at: String, - 24
} - 25
- 26
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 27
#[serde(tag = "event", rename_all = "snake_case")] - 28
enum GrantEvent { - 29
Invited { - 30
grant: AudienceGrant, - 31
}, - 32
Revoked { - 33
grant_id: String, - 34
revoked_at: String, - 35
actor_id: String, - 36
}, - 37
} - 38
- 39
#[derive(Debug, Clone, PartialEq, Eq)] - 40
pub struct VerifiedPrincipal { - 41
pub grant_id: String, - 42
pub principal_id: String, - 43
pub display_name: String, - 44
pub conversation_id: String, - 45
pub audience_id: String, - 46
pub capabilities: Vec<String>, - 47
} - 48
- 49
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] - 50
pub struct GrantSummary { - 51
pub grant_id: String, - 52
pub principal_id: String, - 53
pub display_name: String, - 54
pub conversation_id: String, - 55
pub audience_id: String, - 56
pub capabilities: Vec<String>, - 57
pub created_at: String, - 58
pub expires_at: String, - 59
pub status: GrantStatus, - 60
#[serde(skip_serializing_if = "Option::is_none")] - 61
pub revoked_at: Option<String>, - 62
} - 63
- 64
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] - 65
#[serde(rename_all = "snake_case")] - 66
pub enum GrantStatus { - 67
Active, - 68
Expired, - 69
Revoked, - 70
} - 71
- 72
#[derive(Debug, thiserror::Error)] - 73
pub enum Error { - 74
#[error("grant store error: {0}")] - 75
Io(#[from] std::io::Error), - 76
#[error("grant record is invalid: {0}")] - 77
Invalid(String), - 78
} - 79
- 80
pub fn store_path(sessions_home: &Path) -> PathBuf { - 81
sessions_home.join("coworking").join("grants.jsonl") - 82
} - 83
- 84
pub fn generate_token() -> String { - 85
format!( - 86
"{}{}", - 87
uuid::Uuid::now_v7().simple(), - 88
uuid::Uuid::now_v7().simple() - 89
) - 90
} - 91
- 92
pub fn token_hash(token: &str) -> String { - 93
format!("sha256:{:x}", Sha256::digest(token.as_bytes())) - 94
} - 95
- 96
fn append(path: &Path, event: &GrantEvent) -> Result<(), Error> { - 97
if let Some(parent) = path.parent() { - 98
std::fs::create_dir_all(parent)?; - 99
} - 100
let mut line = serde_json::to_vec(event).map_err(|error| Error::Invalid(error.to_string()))?; - 101
line.push(b'\n'); - 102
let mut file = std::fs::OpenOptions::new() - 103
.create(true) - 104
.append(true) - 105
.open(path)?; - 106
file.write_all(&line)?; - 107
file.sync_data()?; - 108
Ok(()) - 109
} - 110
- 111
fn load(path: &Path) -> Result<Vec<GrantEvent>, Error> { - 112
if !path.exists() { - 113
return Ok(Vec::new()); - 114
} - 115
std::fs::read_to_string(path)? - 116
.lines() - 117
.filter(|line| !line.trim().is_empty()) - 118
.map(|line| serde_json::from_str(line).map_err(|error| Error::Invalid(error.to_string()))) - 119
.collect() - 120
} - 121
- 122
pub fn invite(path: &Path, grant: AudienceGrant) -> Result<(), Error> { - 123
if grant.principal_id.trim().is_empty() - 124
|| grant.conversation_id.trim().is_empty() - 125
|| grant.audience_id.trim().is_empty() - 126
|| grant.token_hash.trim().is_empty() - 127
|| grant.capabilities.is_empty() - 128
{ - 129
return Err(Error::Invalid("grant fields must be explicit".into())); - 130
} - 131
if load(path)?.iter().any( - 132
|event| matches!(event, GrantEvent::Invited { grant: existing } if existing.grant_id == grant.grant_id), - 133
) { - 134
return Err(Error::Invalid("grant id already exists".into())); - 135
} - 136
append(path, &GrantEvent::Invited { grant }) - 137
} - 138
- 139
pub fn revoke(path: &Path, grant_id: &str, actor_id: &str) -> Result<(), Error> { - 140
if grant_id.trim().is_empty() || actor_id.trim().is_empty() { - 141
return Err(Error::Invalid("revocation identity is required".into())); - 142
} - 143
append( - 144
path, - 145
&GrantEvent::Revoked { - 146
grant_id: grant_id.into(), - 147
revoked_at: chrono::Utc::now().to_rfc3339(), - 148
actor_id: actor_id.into(), - 149
}, - 150
) - 151
} - 152
- 153
pub fn verify( - 154
path: &Path, - 155
token: &str, - 156
now: chrono::DateTime<chrono::Utc>, - 157
) -> Result<Option<VerifiedPrincipal>, Error> { - 158
let events = load(path)?; - 159
let revoked: std::collections::HashSet<&str> = events - 160
.iter() - 161
.filter_map(|event| match event { - 162
GrantEvent::Revoked { grant_id, .. } => Some(grant_id.as_str()), - 163
_ => None, - 164
}) - 165
.collect(); - 166
let supplied = token_hash(token); - 167
for event in events.iter().rev() { - 168
let GrantEvent::Invited { grant } = event else { - 169
continue; - 170
}; - 171
if revoked.contains(grant.grant_id.as_str()) { - 172
continue; - 173
} - 174
let matches: bool = supplied - 175
.as_bytes() - 176
.ct_eq(grant.token_hash.as_bytes()) - 177
.into(); - 178
if !matches { - 179
continue; - 180
} - 181
let expires = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - 182
.map_err(|error| Error::Invalid(error.to_string()))? - 183
.with_timezone(&chrono::Utc); - 184
if expires <= now { - 185
return Ok(None); - 186
} - 187
return Ok(Some(VerifiedPrincipal { - 188
grant_id: grant.grant_id.clone(), - 189
principal_id: grant.principal_id.clone(), - 190
display_name: grant.display_name.clone(), - 191
conversation_id: grant.conversation_id.clone(), - 192
audience_id: grant.audience_id.clone(), - 193
capabilities: grant.capabilities.clone(), - 194
})); - 195
} - 196
Ok(None) - 197
} - 198
- 199
pub fn list( - 200
path: &Path, - 201
conversation_id: &str, - 202
now: chrono::DateTime<chrono::Utc>, - 203
) -> Result<Vec<GrantSummary>, Error> { - 204
let events = load(path)?; - 205
let revoked: std::collections::HashMap<&str, &str> = events - 206
.iter() - 207
.filter_map(|event| match event { - 208
GrantEvent::Revoked { - 209
grant_id, - 210
revoked_at, - 211
.. - 212
} => Some((grant_id.as_str(), revoked_at.as_str())), - 213
_ => None, - 214
}) - 215
.collect(); - 216
let mut summaries = Vec::new(); - 217
for event in &events { - 218
let GrantEvent::Invited { grant } = event else { - 219
continue; - 220
}; - 221
if grant.conversation_id != conversation_id { - 222
continue; - 223
} - 224
let expires = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) - 225
.map_err(|error| Error::Invalid(error.to_string()))? - 226
.with_timezone(&chrono::Utc); - 227
let revoked_at = revoked.get(grant.grant_id.as_str()).copied(); - 228
summaries.push(GrantSummary { - 229
grant_id: grant.grant_id.clone(), - 230
principal_id: grant.principal_id.clone(), - 231
display_name: grant.display_name.clone(), - 232
conversation_id: grant.conversation_id.clone(), - 233
audience_id: grant.audience_id.clone(), - 234
capabilities: grant.capabilities.clone(), - 235
created_at: grant.created_at.clone(), - 236
expires_at: grant.expires_at.clone(), - 237
status: if revoked_at.is_some() { - 238
GrantStatus::Revoked - 239
} else if expires <= now { - 240
GrantStatus::Expired - 241
} else { - 242
GrantStatus::Active - 243
}, - 244
revoked_at: revoked_at.map(ToOwned::to_owned), - 245
}); - 246
} - 247
summaries.sort_by(|left, right| right.created_at.cmp(&left.created_at)); - 248
Ok(summaries) - 249
} - 250
- 251
#[cfg(test)] - 252
#[allow(clippy::unwrap_used, clippy::expect_used)] - 253
mod tests { - 254
use super::*; - 255
- 256
fn grant(token: &str, expires_at: &str) -> AudienceGrant { - 257
AudienceGrant { - 258
grant_id: "grant-1".into(), - 259
principal_id: "person-2".into(), - 260
display_name: "Asha".into(), - 261
conversation_id: "session-1".into(), - 262
audience_id: "conversation:session-1".into(), - 263
capabilities: vec!["read".into(), "comment".into()], - 264
token_hash: token_hash(token), - 265
created_at: "2026-09-20T00:00:00Z".into(), - 266
expires_at: expires_at.into(), - 267
} - 268
} - 269
- 270
#[test] - 271
fn verifies_scope_without_storing_raw_token() { - 272
let dir = tempfile::tempdir().unwrap(); - 273
let path = store_path(dir.path()); - 274
let token = generate_token(); - 275
invite(&path, grant(&token, "2026-09-22T00:00:00Z")).unwrap(); - 276
let text = std::fs::read_to_string(&path).unwrap(); - 277
assert!(!text.contains(&token)); - 278
let principal = verify( - 279
&path, - 280
&token, - 281
chrono::DateTime::parse_from_rfc3339("2026-09-21T00:00:00Z") - 282
.unwrap() - 283
.into(), - 284
) - 285
.unwrap() - 286
.unwrap(); - 287
assert_eq!(principal.principal_id, "person-2"); - 288
assert_eq!(principal.capabilities, vec!["read", "comment"]); - 289
} - 290
- 291
#[test] - 292
fn expiry_and_revocation_fail_closed() { - 293
let dir = tempfile::tempdir().unwrap(); - 294
let path = store_path(dir.path()); - 295
invite(&path, grant("expired", "2026-09-20T00:00:00Z")).unwrap(); - 296
let now = chrono::DateTime::parse_from_rfc3339("2026-09-21T00:00:00Z") - 297
.unwrap() - 298
.into(); - 299
assert!(verify(&path, "expired", now).unwrap().is_none()); - 300
- 301
let mut revoked = grant("revoked", "2026-09-22T00:00:00Z"); - 302
revoked.grant_id = "grant-2".into(); - 303
invite(&path, revoked).unwrap(); - 304
revoke(&path, "grant-2", "operator").unwrap(); - 305
assert!(verify(&path, "revoked", now).unwrap().is_none()); - 306
} - 307
- 308
#[test] - 309
fn listing_omits_tokens_and_reports_lifecycle() { - 310
let dir = tempfile::tempdir().unwrap(); - 311
let path = store_path(dir.path()); - 312
invite(&path, grant("secret-token", "2026-09-22T00:00:00Z")).unwrap(); - 313
let duplicate = invite(&path, grant("replacement", "2026-09-23T00:00:00Z")); - 314
assert!(duplicate.is_err()); - 315
let now = chrono::DateTime::parse_from_rfc3339("2026-09-21T00:00:00Z") - 316
.unwrap() - 317
.into(); - 318
let listed = list(&path, "session-1", now).unwrap(); - 319
assert_eq!(listed.len(), 1); - 320
assert_eq!(listed[0].status, GrantStatus::Active); - 321
let serialized = serde_json::to_string(&listed).unwrap(); - 322
assert!(!serialized.contains("token_hash")); - 323
assert!(!serialized.contains("secret-token")); - 324
- 325
revoke(&path, "grant-1", "operator").unwrap(); - 326
let listed = list(&path, "session-1", now).unwrap(); - 327
assert_eq!(listed[0].status, GrantStatus::Revoked); - 328
assert!(listed[0].revoked_at.is_some()); - 329
} - 330
} - 331
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.