Wire auth explain to local auth ops
This commit is contained in:
parent
187b99eb8d
commit
f54920bb65
9 changed files with 299 additions and 19 deletions
|
|
@ -142,6 +142,17 @@ pub enum AuthCommand {
|
|||
resource: String,
|
||||
capability: String,
|
||||
},
|
||||
Grant {
|
||||
subject: String,
|
||||
resource: String,
|
||||
capability: String,
|
||||
#[arg(long)]
|
||||
grant_id: Option<String>,
|
||||
},
|
||||
Revoke {
|
||||
resource: String,
|
||||
grant_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
|
|
@ -350,6 +361,23 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
|||
resource,
|
||||
capability,
|
||||
},
|
||||
Command::Auth {
|
||||
command:
|
||||
AuthCommand::Grant {
|
||||
subject,
|
||||
resource,
|
||||
capability,
|
||||
grant_id,
|
||||
},
|
||||
} => ControlRequest::AuthGrant {
|
||||
subject,
|
||||
resource,
|
||||
capability,
|
||||
grant_id,
|
||||
},
|
||||
Command::Auth {
|
||||
command: AuthCommand::Revoke { resource, grant_id },
|
||||
} => ControlRequest::AuthRevoke { resource, grant_id },
|
||||
Command::Secret { command } => ControlRequest::ModuleStub {
|
||||
module: "secret".to_owned(),
|
||||
command: format!("{command:?}"),
|
||||
|
|
@ -575,6 +603,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
|||
println!("reason: {}", explain.reason);
|
||||
println!("evaluated_ops: {}", explain.evaluated_ops);
|
||||
}
|
||||
ControlResponse::AuthOpRecorded { op } => {
|
||||
println!("recorded auth op: {}", op.id);
|
||||
println!("resource: {}", op.resource);
|
||||
}
|
||||
ControlResponse::SshCertRequested { request } => {
|
||||
println!("ssh cert request: {}", request.id);
|
||||
println!("status: {}", request.status);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use geth_auth::AuthExplanation;
|
||||
use geth_auth::{AuthExplanation, AuthOp};
|
||||
use geth_resource::ResourceDescriptor;
|
||||
use geth_ssh_identity::{
|
||||
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
||||
|
|
@ -37,6 +37,16 @@ pub enum ControlRequest {
|
|||
resource: String,
|
||||
capability: String,
|
||||
},
|
||||
AuthGrant {
|
||||
subject: String,
|
||||
resource: String,
|
||||
capability: String,
|
||||
grant_id: Option<String>,
|
||||
},
|
||||
AuthRevoke {
|
||||
resource: String,
|
||||
grant_id: String,
|
||||
},
|
||||
SshCertRequest {
|
||||
public_key_path: PathBuf,
|
||||
cert_kind: String,
|
||||
|
|
@ -105,6 +115,9 @@ pub enum ControlResponse {
|
|||
},
|
||||
KeychainStatus(KeychainStatusResponse),
|
||||
AuthExplain(AuthExplanation),
|
||||
AuthOpRecorded {
|
||||
op: AuthOp,
|
||||
},
|
||||
SshCertRequested {
|
||||
request: SshCertRequest,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
pub mod service;
|
||||
|
||||
use geth_auth::AuthExplanation;
|
||||
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
|
||||
use geth_cas::{LocalCas, hash_path};
|
||||
use geth_config::{GethConfig, GethPaths, RelayMode};
|
||||
use geth_control::{
|
||||
|
|
@ -16,10 +16,12 @@ use geth_ssh_identity::{
|
|||
certificate_id, revocation_id, ssh_public_key_fingerprint,
|
||||
};
|
||||
use geth_store::{
|
||||
Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
|
||||
Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate,
|
||||
StoredSshRevocation,
|
||||
};
|
||||
use geth_types::{
|
||||
NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis,
|
||||
AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId,
|
||||
SshCertRequestId, UnixMillis,
|
||||
};
|
||||
use std::path::Path;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
|
@ -249,16 +251,67 @@ pub fn handle_request(
|
|||
resource,
|
||||
capability,
|
||||
} => {
|
||||
if store.get_peer_card(&subject)?.is_some() {
|
||||
Ok(ControlResponse::AuthExplain(
|
||||
AuthExplanation::discovered_candidate(subject, resource, capability),
|
||||
))
|
||||
let ops = load_auth_ops_for_resource(&store, &resource)?;
|
||||
let discovered = store.get_peer_card(&subject)?.is_some();
|
||||
if ops.is_empty() {
|
||||
if discovered {
|
||||
Ok(ControlResponse::AuthExplain(
|
||||
AuthExplanation::discovered_candidate(subject, resource, capability),
|
||||
))
|
||||
} else {
|
||||
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
|
||||
subject, resource, capability,
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
|
||||
subject, resource, capability,
|
||||
)))
|
||||
let mut explanation = geth_auth::explain_auth_ops(
|
||||
&ops,
|
||||
PrincipalId::new(subject.clone()),
|
||||
ResourceId::new(resource.clone()),
|
||||
Capability::new(capability.clone()),
|
||||
);
|
||||
if discovered && !explanation.allowed {
|
||||
explanation.reason = format!(
|
||||
"subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}",
|
||||
explanation.reason
|
||||
);
|
||||
}
|
||||
Ok(ControlResponse::AuthExplain(explanation))
|
||||
}
|
||||
}
|
||||
ControlRequest::AuthGrant {
|
||||
subject,
|
||||
resource,
|
||||
capability,
|
||||
grant_id,
|
||||
} => {
|
||||
let created_at = UnixMillis(geth_store::now_ms());
|
||||
let grant_id =
|
||||
grant_id.unwrap_or_else(|| generated_grant_id(&subject, &resource, &capability));
|
||||
let op = AuthOp {
|
||||
id: generated_auth_op_id("grant-create", &resource, &grant_id, created_at),
|
||||
resource: ResourceId::new(resource),
|
||||
created_at,
|
||||
kind: AuthOpKind::GrantCreate {
|
||||
grant_id,
|
||||
principal: PrincipalId::new(subject),
|
||||
capabilities: vec![Capability::new(capability)],
|
||||
},
|
||||
};
|
||||
store_auth_op(&store, &op)?;
|
||||
Ok(ControlResponse::AuthOpRecorded { op })
|
||||
}
|
||||
ControlRequest::AuthRevoke { resource, grant_id } => {
|
||||
let created_at = UnixMillis(geth_store::now_ms());
|
||||
let op = AuthOp {
|
||||
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
|
||||
resource: ResourceId::new(resource),
|
||||
created_at,
|
||||
kind: AuthOpKind::GrantRevoke { grant_id },
|
||||
};
|
||||
store_auth_op(&store, &op)?;
|
||||
Ok(ControlResponse::AuthOpRecorded { op })
|
||||
}
|
||||
ControlRequest::SshCertRequest {
|
||||
public_key_path,
|
||||
cert_kind,
|
||||
|
|
@ -447,6 +500,45 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
|
|||
))
|
||||
}
|
||||
|
||||
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
|
||||
store.insert_auth_op(&StoredAuthOp {
|
||||
op_id: op.id.to_string(),
|
||||
resource_id: op.resource.to_string(),
|
||||
op_json: serde_json::to_string(op)?,
|
||||
created_at_ms: op.created_at.0,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthOp>, NodeError> {
|
||||
store
|
||||
.list_auth_ops_for_resource(resource)?
|
||||
.into_iter()
|
||||
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
|
||||
format!(
|
||||
"grant:{}",
|
||||
geth_crypto::blake3_hex(format!("{subject}\0{resource}\0{capability}").as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn generated_auth_op_id(
|
||||
kind: &str,
|
||||
resource: &str,
|
||||
stable_id: &str,
|
||||
created_at: UnixMillis,
|
||||
) -> AuthOpId {
|
||||
AuthOpId::new(format!(
|
||||
"auth-op:{}",
|
||||
geth_crypto::blake3_hex(
|
||||
format!("{}\0{kind}\0{resource}\0{stable_id}", created_at.0).as_bytes()
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn stable_node_id(agent_id: &str) -> String {
|
||||
format!("node:{agent_id}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,6 +289,35 @@ impl Store {
|
|||
.map_err(StoreError::from)
|
||||
}
|
||||
|
||||
pub fn insert_auth_op(&self, op: &StoredAuthOp) -> Result<(), StoreError> {
|
||||
self.conn.execute(
|
||||
r#"INSERT OR REPLACE INTO auth_ops(op_id, resource_id, op_json, created_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4)"#,
|
||||
params![op.op_id, op.resource_id, op.op_json, op.created_at_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_auth_ops_for_resource(
|
||||
&self,
|
||||
resource_id: &str,
|
||||
) -> Result<Vec<StoredAuthOp>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
r#"SELECT op_id, resource_id, op_json, created_at_ms
|
||||
FROM auth_ops WHERE resource_id = ?1 ORDER BY created_at_ms, op_id"#,
|
||||
)?;
|
||||
let rows = stmt.query_map(params![resource_id], |row| {
|
||||
Ok(StoredAuthOp {
|
||||
op_id: row.get(0)?,
|
||||
resource_id: row.get(1)?,
|
||||
op_json: row.get(2)?,
|
||||
created_at_ms: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(StoreError::from)
|
||||
}
|
||||
|
||||
pub fn insert_ssh_cert_request(
|
||||
&self,
|
||||
request: &StoredSshCertRequest,
|
||||
|
|
@ -476,6 +505,14 @@ pub struct StoredPeerCard {
|
|||
pub updated_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoredAuthOp {
|
||||
pub op_id: String,
|
||||
pub resource_id: String,
|
||||
pub op_json: String,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoredSshCertRequest {
|
||||
pub request_id: String,
|
||||
|
|
@ -589,4 +626,31 @@ mod tests {
|
|||
);
|
||||
assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_ops_roundtrip_by_resource() {
|
||||
let store = Store::open_memory().expect("open");
|
||||
let first = StoredAuthOp {
|
||||
op_id: "op:auth:1".to_owned(),
|
||||
resource_id: "resource:notes".to_owned(),
|
||||
op_json: r#"{"id":"op:auth:1"}"#.to_owned(),
|
||||
created_at_ms: 1,
|
||||
};
|
||||
let second = StoredAuthOp {
|
||||
op_id: "op:auth:2".to_owned(),
|
||||
resource_id: "resource:other".to_owned(),
|
||||
op_json: r#"{"id":"op:auth:2"}"#.to_owned(),
|
||||
created_at_ms: 2,
|
||||
};
|
||||
|
||||
store.insert_auth_op(&second).expect("insert second");
|
||||
store.insert_auth_op(&first).expect("insert first");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.list_auth_ops_for_resource("resource:notes")
|
||||
.expect("list auth ops"),
|
||||
vec![first]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,75 @@ fn auth_explain_distinguishes_discovered_peer_candidates() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_grant_revoke_and_explain_use_local_auth_log() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let paths = geth_config::GethPaths::from_home(home.path());
|
||||
let node = geth_node::init_node(&paths).expect("init node");
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::AuthGrant {
|
||||
subject: "node:laptop".to_owned(),
|
||||
resource: "resource:cas:local".to_owned(),
|
||||
capability: "cas.fetch".to_owned(),
|
||||
grant_id: Some("grant:test-fetch".to_owned()),
|
||||
},
|
||||
)
|
||||
.expect("grant capability");
|
||||
match response {
|
||||
geth_control::ControlResponse::AuthOpRecorded { op } => {
|
||||
assert_eq!(op.resource.to_string(), "resource:cas:local");
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::AuthExplain {
|
||||
subject: "node:laptop".to_owned(),
|
||||
resource: "resource:cas:local".to_owned(),
|
||||
capability: "cas.fetch".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("explain grant");
|
||||
match response {
|
||||
geth_control::ControlResponse::AuthExplain(explanation) => {
|
||||
assert!(explanation.allowed);
|
||||
assert_eq!(explanation.evaluated_ops, 1);
|
||||
assert!(explanation.reason.contains("grant:test-fetch"));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
|
||||
geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::AuthRevoke {
|
||||
resource: "resource:cas:local".to_owned(),
|
||||
grant_id: "grant:test-fetch".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("revoke grant");
|
||||
|
||||
let response = geth_node::handle_request(
|
||||
&node,
|
||||
geth_control::ControlRequest::AuthExplain {
|
||||
subject: "node:laptop".to_owned(),
|
||||
resource: "resource:cas:local".to_owned(),
|
||||
capability: "cas.fetch".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("explain revoke");
|
||||
match response {
|
||||
geth_control::ControlResponse::AuthExplain(explanation) => {
|
||||
assert!(!explanation.allowed);
|
||||
assert_eq!(explanation.evaluated_ops, 2);
|
||||
assert!(explanation.reason.contains("no active"));
|
||||
}
|
||||
other => panic!("unexpected response: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
|
|
|
|||
Loading…
Reference in a new issue