Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/rds-agent/src/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,21 @@ pub(crate) async fn authorize(
return Err(error.into());
}
};
// Deployment binding: when policy pins a tenant or a policy-revision
// floor, the grant must carry matching v3 claims. Version-2 grants carry
// no claims and therefore fail any pinned binding.
if let Some(tenant) = &policy.tenant
&& verified.tenant() != Some(tenant.as_str())
{
rds_observe::request_refused(rds_observe::Reason::Denied);
return Err(grant::GrantError::TenantMismatch.into());
}
if let Some(floor) = policy.min_policy_revision
&& verified.policy_revision().is_none_or(|r| r < floor)
{
rds_observe::request_refused(rds_observe::Reason::Denied);
return Err(grant::GrantError::PolicyRevisionStale.into());
}
let id = verified.id;
let next = if renewal {
Some(
Expand Down Expand Up @@ -913,6 +928,8 @@ mod tests {
not_before: 100,
expires_at: 160,
constraints: Default::default(),
tenant: None,
policy_revision: None,
},
)
.verify(
Expand Down
47 changes: 47 additions & 0 deletions crates/rds-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ pub struct AgentPolicy {
pub services: Option<BTreeSet<ServiceKind>>,
/// Admission and greeting deadlines applied per connection/stream.
pub timeouts: TimeoutPolicy,
/// Tenant this agent belongs to (v3 grant claim). `Some` pins the
/// deployment: every grant must carry the same `tenant` claim —
/// unscoped and mismatched grants are refused at authorization.
pub tenant: Option<String>,
/// Minimum estate policy revision a grant must claim (v3). `Some`
/// retires grants minted before a policy change without waiting for
/// their expiry or a revocation snapshot.
pub min_policy_revision: Option<u64>,
}

impl std::fmt::Debug for AgentPolicy {
Expand All @@ -140,6 +148,8 @@ impl std::fmt::Debug for AgentPolicy {
.field("sync_dir", &self.sync_dir)
.field("services", &self.services)
.field("timeouts", &self.timeouts)
.field("tenant", &self.tenant)
.field("min_policy_revision", &self.min_policy_revision)
.finish_non_exhaustive()
}
}
Expand All @@ -157,6 +167,8 @@ impl AgentPolicy {
sync_dir: None,
services: None,
timeouts: TimeoutPolicy::default(),
tenant: None,
min_policy_revision: None,
}
}

Expand Down Expand Up @@ -246,6 +258,22 @@ impl AgentPolicy {
{
return Err("timeouts must be between 1 and 3600 seconds");
}
if let Some(tenant) = &self.tenant
&& (tenant.is_empty()
|| tenant.len() > rds_core::grant::MAX_TENANT_LEN
|| tenant.bytes().any(|b| b < 0x21 || b == 0x7f))
{
return Err(
"tenant must be nonempty, at most 64 bytes and free of control or whitespace bytes",
);
}
// A pinned binding with no trusted issuers never evaluates: grants
// are not required, so every connection would pass unscoped. Fail
// loudly instead of silently dropping the deployment's binding.
if self.issuers.is_empty() && (self.tenant.is_some() || self.min_policy_revision.is_some())
{
return Err("tenant/policy-revision binding requires grant issuers");
}
Ok(())
}

Expand Down Expand Up @@ -827,9 +855,28 @@ async fn serve_stream(
let access = grant
.as_ref()
.map_or(rds_sync::engine::Access::READ_WRITE, |g| {
// Grant v3 `sync_paths` entries passed the decoder's
// lexical checks; normalize `.`/empty components the
// same way `check_rel_path` normalizes requests so
// prefix matching compares like with like.
let paths = g.payload.constraints.sync_paths.as_ref().map(|list| {
list.iter()
.map(|scope| {
std::path::Path::new(scope)
.components()
.filter_map(|c| match c {
std::path::Component::Normal(p) => Some(p),
_ => None,
})
.collect::<PathBuf>()
})
.collect::<Vec<_>>()
.into()
});
rds_sync::engine::Access {
read: g.permits_sync_read(),
write: g.permits_sync_write(),
paths,
}
});
if let Some(transfer) = transfer {
Expand Down
12 changes: 12 additions & 0 deletions crates/rds-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ struct Cli {
/// Maximum grant lifetime accepted, in seconds (default 300).
#[arg(long)]
grant_ttl: Option<u64>,
/// Tenant this device belongs to (grant v3). When set, every grant
/// must carry the same `tenant` claim; unscoped grants are refused.
#[arg(long)]
tenant: Option<String>,
/// Minimum policy revision a grant must claim (grant v3). Grants
/// minted under older estate policy are refused.
#[arg(long)]
policy_min_revision: Option<u64>,
/// Verifying key that signs the estate revocation snapshot
/// (`GET /v1/revocations`). Required for denylist polling when
/// `--directory` is set.
Expand Down Expand Up @@ -180,6 +188,8 @@ async fn run(cli: Cli) -> anyhow::Result<()> {
allow: cli.allow,
issuers: cli.issuers,
grant_ttl: cli.grant_ttl,
tenant: cli.tenant,
policy_min_revision: cli.policy_min_revision,
directory: cli.directory,
directory_ca: cli.directory_ca,
record_ttl: cli.record_ttl,
Expand Down Expand Up @@ -234,6 +244,8 @@ async fn run(cli: Cli) -> anyhow::Result<()> {
if let Some(timeouts) = resolved.timeouts {
policy.timeouts = timeouts;
}
policy.tenant = resolved.tenant;
policy.min_policy_revision = resolved.policy_min_revision;
policy.issuers.extend(resolved.issuers.iter().copied());
policy
.validate()
Expand Down
71 changes: 71 additions & 0 deletions crates/rds-agent/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ pub struct AuthoritySettings {
/// Trusted grant issuers (base32 Ed25519 verifying keys).
pub issuers: Vec<String>,
pub grant_ttl_secs: Option<u64>,
/// Tenant this agent answers (grant v3 claim binding). When set, every
/// grant must carry the same `tenant`; unscoped grants are refused.
pub tenant: Option<String>,
/// Minimum `policy_revision` a grant must claim (grant v3). Grants
/// minted under older estate policy are refused without waiting for
/// expiry or revocation.
pub policy_min_revision: Option<u64>,
/// Directory HTTP(S) origin or legacy IP:port.
pub directory: Option<String>,
pub directory_ca: Option<PathBuf>,
Expand Down Expand Up @@ -235,6 +242,8 @@ pub struct AgentOverrides {
pub allow: Vec<String>,
pub issuers: Vec<String>,
pub grant_ttl: Option<u64>,
pub tenant: Option<String>,
pub policy_min_revision: Option<u64>,
pub directory: Option<String>,
pub directory_ca: Option<PathBuf>,
pub record_ttl: Option<u64>,
Expand Down Expand Up @@ -267,6 +276,8 @@ pub struct ResolvedAgent {
pub allow: Vec<EndpointId>,
pub issuers: Vec<[u8; 32]>,
pub grant_ttl: Option<u64>,
pub tenant: Option<String>,
pub policy_min_revision: Option<u64>,
pub directory: Option<String>,
pub directory_ca: Option<PathBuf>,
pub record_ttl: Option<u64>,
Expand Down Expand Up @@ -381,6 +392,12 @@ impl AgentSettings {
if flags.grant_ttl.is_some() {
self.authority.grant_ttl_secs = flags.grant_ttl;
}
if flags.tenant.is_some() {
self.authority.tenant = flags.tenant;
}
if flags.policy_min_revision.is_some() {
self.authority.policy_min_revision = flags.policy_min_revision;
}
if flags.directory.is_some() {
self.authority.directory = flags.directory;
}
Expand Down Expand Up @@ -498,6 +515,22 @@ impl AgentSettings {
"grant_ttl_secs must be 1..=86400",
));
}
if let Some(tenant) = &authority.tenant
&& (tenant.is_empty()
|| tenant.len() > rds_core::grant::MAX_TENANT_LEN
|| tenant.bytes().any(|b| b < 0x21 || b == 0x7f))
{
return Err(AgentConfigError::Invalid(
"tenant must be nonempty, at most 64 bytes and free of control or whitespace bytes",
));
}
if authority.issuers.is_empty()
&& (authority.tenant.is_some() || authority.policy_min_revision.is_some())
{
return Err(AgentConfigError::Invalid(
"tenant/policy_min_revision require at least one grant issuer",
));
}
if authority.directory.is_none()
&& (authority.directory_ca.is_some()
|| authority.record_ttl_secs.is_some()
Expand Down Expand Up @@ -638,6 +671,8 @@ impl AgentSettings {
allow,
issuers,
grant_ttl: authority.grant_ttl_secs,
tenant: authority.tenant.clone(),
policy_min_revision: authority.policy_min_revision,
directory: authority.directory.clone(),
directory_ca: authority.directory_ca.clone(),
record_ttl: authority.record_ttl_secs,
Expand Down Expand Up @@ -914,4 +949,40 @@ mod tests {
);
assert_eq!(resolve_ok(r#"{"schema_version":1}"#).timeouts, None);
}

#[test]
fn tenant_and_revision_binding() {
// Binding claims without issuers would never evaluate — refused
// loudly rather than silently inert.
for json in [
r#"{"schema_version":1,"authority":{"tenant":"t1"}}"#,
r#"{"schema_version":1,"authority":{"policy_min_revision":4}}"#,
r#"{"schema_version":1,"authority":{"tenant":"","issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"directory":"http://localhost:9","revocations":{"key":"k"}}}"#,
r#"{"schema_version":1,"authority":{"tenant":"has space","issuers":["a"],"revocations":{"key":"k"}}}"#,
] {
let settings = parse(json);
assert!(settings.validate().is_err(), "{json}");
}
// A valid binding resolves, and flag values override file values.
let resolved = resolve_ok(
r#"{"schema_version":1,"authority":{"issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
"tenant":"tenant-a","policy_min_revision":3,"directory":"http://localhost:9",
"revocations":{"key":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}"#,
);
assert_eq!(resolved.tenant.as_deref(), Some("tenant-a"));
assert_eq!(resolved.policy_min_revision, Some(3));
let settings = parse(
r#"{"schema_version":1,"authority":{"issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
"tenant":"tenant-a","policy_min_revision":3,"directory":"http://localhost:9",
"revocations":{"key":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}"#,
)
.apply(AgentOverrides {
tenant: Some("tenant-b".into()),
..Default::default()
});
assert_eq!(
settings.resolve().unwrap().tenant.as_deref(),
Some("tenant-b")
);
}
}
2 changes: 2 additions & 0 deletions crates/rds-agent/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ async fn expired_and_wrong_service_grants_rejected() {
not_before: 1,
expires_at: 2,
constraints: GrantConstraints::default(),
tenant: None,
policy_revision: None,
},
);
assert!(
Expand Down
Loading
Loading