Public dry run dryrun-6341cce4cf9f
Source commit: 6341cce4cf9f801a3bfe2248d6e7e6a119cea715 Public tree identity: sha256:d909cba912925e66131fc55fe33774a2b26b1ff2c12b53ec4ef217c9788d8b7a
This commit is contained in:
parent
84ef57d84b
commit
9f2d6ec010
7 changed files with 654 additions and 27 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"kind": "disasmer-filtered-public-tree",
|
"kind": "disasmer-filtered-public-tree",
|
||||||
"source_commit": "199c53541aa226880d242f7bb5c5d122961d9d87",
|
"source_commit": "6341cce4cf9f801a3bfe2248d6e7e6a119cea715",
|
||||||
"release_name": "dryrun-199c53541aa2",
|
"release_name": "dryrun-6341cce4cf9f",
|
||||||
"filtered_out": [
|
"filtered_out": [
|
||||||
"private/**",
|
"private/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
|
|
|
||||||
|
|
@ -771,18 +771,64 @@ fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result<Value> {
|
||||||
|
|
||||||
fn key_add_report(args: KeyAddArgs) -> Result<Value> {
|
fn key_add_report(args: KeyAddArgs) -> Result<Value> {
|
||||||
if let Some(coordinator) = &args.scope.coordinator {
|
if let Some(coordinator) = &args.scope.coordinator {
|
||||||
|
let tenant = args.scope.tenant.clone();
|
||||||
|
let project = args.scope.project.clone();
|
||||||
|
let user = args.scope.user.clone();
|
||||||
|
let agent = args.agent.clone();
|
||||||
|
let public_key_fingerprint = Digest::sha256(&args.public_key);
|
||||||
let mut session = JsonLineSession::connect(coordinator)?;
|
let mut session = JsonLineSession::connect(coordinator)?;
|
||||||
let response = session.request(json!({
|
let response = session.request(json!({
|
||||||
"type": "register_agent_public_key",
|
"type": "register_agent_public_key",
|
||||||
"tenant": args.scope.tenant,
|
"tenant": tenant,
|
||||||
"project": args.scope.project,
|
"project": project,
|
||||||
"user": args.scope.user,
|
"user": user,
|
||||||
"agent": args.agent,
|
"agent": agent,
|
||||||
"public_key": args.public_key,
|
"public_key": args.public_key,
|
||||||
}))?;
|
}))?;
|
||||||
|
let record = response.get("record").cloned().unwrap_or_else(|| {
|
||||||
|
json!({
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
"agent": agent,
|
||||||
|
"public_key_fingerprint": public_key_fingerprint,
|
||||||
|
"scopes": ["project:read", "project:run"],
|
||||||
|
"human_account_creation_privilege": false,
|
||||||
|
"browser_interaction_required_each_run": false,
|
||||||
|
})
|
||||||
|
});
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"command": "key add",
|
"command": "key add",
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
"agent": agent,
|
||||||
|
"public_key_fingerprint": record
|
||||||
|
.get("public_key_fingerprint")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(public_key_fingerprint)),
|
||||||
|
"credential_scope": {
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"actions": record
|
||||||
|
.get("scopes")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(["project:read", "project:run"])),
|
||||||
|
"human_account_creation_privilege": record
|
||||||
|
.get("human_account_creation_privilege")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(false)),
|
||||||
|
},
|
||||||
|
"browser_interaction_required_each_run": record
|
||||||
|
.get("browser_interaction_required_each_run")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(false)),
|
||||||
|
"attribution": {
|
||||||
|
"registered_by_user": user,
|
||||||
|
"agent": agent,
|
||||||
|
"credential_kind": "public_key",
|
||||||
|
},
|
||||||
"response": response,
|
"response": response,
|
||||||
"coordinator_session_requests": session.requests(),
|
"coordinator_session_requests": session.requests(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -798,16 +844,32 @@ fn key_add_report(args: KeyAddArgs) -> Result<Value> {
|
||||||
|
|
||||||
fn key_list_report(args: KeyListArgs) -> Result<Value> {
|
fn key_list_report(args: KeyListArgs) -> Result<Value> {
|
||||||
if let Some(coordinator) = &args.scope.coordinator {
|
if let Some(coordinator) = &args.scope.coordinator {
|
||||||
|
let tenant = args.scope.tenant.clone();
|
||||||
|
let project = args.scope.project.clone();
|
||||||
|
let user = args.scope.user.clone();
|
||||||
let mut session = JsonLineSession::connect(coordinator)?;
|
let mut session = JsonLineSession::connect(coordinator)?;
|
||||||
let response = session.request(json!({
|
let response = session.request(json!({
|
||||||
"type": "list_agent_public_keys",
|
"type": "list_agent_public_keys",
|
||||||
"tenant": args.scope.tenant,
|
"tenant": tenant,
|
||||||
"project": args.scope.project,
|
"project": project,
|
||||||
"user": args.scope.user,
|
"user": user,
|
||||||
}))?;
|
}))?;
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"command": "key list",
|
"command": "key list",
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
"records": response
|
||||||
|
.get("records")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([])),
|
||||||
|
"credential_scope": {
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"listed_for_user": user,
|
||||||
|
"human_account_creation_privilege": false,
|
||||||
|
},
|
||||||
"response": response,
|
"response": response,
|
||||||
"coordinator_session_requests": session.requests(),
|
"coordinator_session_requests": session.requests(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -821,18 +883,41 @@ fn key_list_report(args: KeyListArgs) -> Result<Value> {
|
||||||
|
|
||||||
fn key_revoke_report(args: KeyRevokeArgs) -> Result<Value> {
|
fn key_revoke_report(args: KeyRevokeArgs) -> Result<Value> {
|
||||||
if let Some(coordinator) = &args.scope.coordinator {
|
if let Some(coordinator) = &args.scope.coordinator {
|
||||||
|
let tenant = args.scope.tenant.clone();
|
||||||
|
let project = args.scope.project.clone();
|
||||||
|
let user = args.scope.user.clone();
|
||||||
|
let agent = args.agent.clone();
|
||||||
let mut session = JsonLineSession::connect(coordinator)?;
|
let mut session = JsonLineSession::connect(coordinator)?;
|
||||||
let response = session.request(json!({
|
let response = session.request(json!({
|
||||||
"type": "revoke_agent_public_key",
|
"type": "revoke_agent_public_key",
|
||||||
"tenant": args.scope.tenant,
|
"tenant": tenant,
|
||||||
"project": args.scope.project,
|
"project": project,
|
||||||
"user": args.scope.user,
|
"user": user,
|
||||||
"agent": args.agent,
|
"agent": agent,
|
||||||
}))?;
|
}))?;
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"command": "key revoke",
|
"command": "key revoke",
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
"requires_confirmation": !args.yes,
|
"requires_confirmation": !args.yes,
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"user": user,
|
||||||
|
"agent": agent,
|
||||||
|
"revoked": response
|
||||||
|
.pointer("/record/revoked")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(true)),
|
||||||
|
"credential_scope": {
|
||||||
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"revoked_for_user": user,
|
||||||
|
"human_account_creation_privilege": false,
|
||||||
|
},
|
||||||
|
"attribution": {
|
||||||
|
"revoked_by_user": user,
|
||||||
|
"agent": agent,
|
||||||
|
"credential_kind": "public_key",
|
||||||
|
},
|
||||||
"response": response,
|
"response": response,
|
||||||
"coordinator_session_requests": session.requests(),
|
"coordinator_session_requests": session.requests(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -4273,6 +4358,84 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_lifecycle_reports_project_scoped_agent_credentials() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let add_response = concat!(
|
||||||
|
r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"#
|
||||||
|
);
|
||||||
|
let list_response = concat!(
|
||||||
|
r#"{"type":"agent_public_keys","actor":"user","records":[{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}]}"#
|
||||||
|
);
|
||||||
|
let revoke_response = concat!(
|
||||||
|
r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":true,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"#
|
||||||
|
);
|
||||||
|
for (expected, response) in [
|
||||||
|
("register_agent_public_key", add_response),
|
||||||
|
("list_agent_public_keys", list_response),
|
||||||
|
("revoke_agent_public_key", revoke_response),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
assert!(line.contains(&format!(r#""type":"{expected}""#)));
|
||||||
|
assert!(line.contains(r#""tenant":"tenant""#));
|
||||||
|
assert!(line.contains(r#""project":"project""#));
|
||||||
|
assert!(line.contains(r#""user":"user""#));
|
||||||
|
if expected != "list_agent_public_keys" {
|
||||||
|
assert!(line.contains(r#""agent":"agent-ci""#));
|
||||||
|
}
|
||||||
|
if expected == "register_agent_public_key" {
|
||||||
|
assert!(line.contains(r#""public_key":"agent-key-v1""#));
|
||||||
|
}
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
stream.write_all(b"\n").unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let scope = CliScopeArgs {
|
||||||
|
coordinator: Some(addr),
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
json: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let added = key_add_report(KeyAddArgs {
|
||||||
|
scope: scope.clone(),
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
public_key: "agent-key-v1".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let listed = key_list_report(KeyListArgs {
|
||||||
|
scope: scope.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let revoked = key_revoke_report(KeyRevokeArgs {
|
||||||
|
scope,
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
yes: true,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
server.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(added["command"], "key add");
|
||||||
|
assert_eq!(added["agent"], "agent-ci");
|
||||||
|
assert_eq!(added["credential_scope"]["actions"][0], "project:read");
|
||||||
|
assert_eq!(
|
||||||
|
added["credential_scope"]["human_account_creation_privilege"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(added["browser_interaction_required_each_run"], false);
|
||||||
|
assert_eq!(added["attribution"]["registered_by_user"], "user");
|
||||||
|
assert_eq!(listed["records"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(listed["credential_scope"]["listed_for_user"], "user");
|
||||||
|
assert_eq!(revoked["revoked"], true);
|
||||||
|
assert_eq!(revoked["attribution"]["revoked_by_user"], "user");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn human_report_is_text_not_json() {
|
fn human_report_is_text_not_json() {
|
||||||
let report = json!({
|
let report = json!({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use disasmer_core::{
|
use disasmer_core::{
|
||||||
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
|
Actor, AgentId, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError,
|
||||||
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
|
EnrollmentGrant, NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId,
|
||||||
|
UserId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
@ -78,6 +79,21 @@ pub struct ProjectPermissionRecord {
|
||||||
pub can_debug: bool,
|
pub can_debug: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AgentPublicKeyRecord {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub user: UserId,
|
||||||
|
pub agent: AgentId,
|
||||||
|
pub public_key: String,
|
||||||
|
pub public_key_fingerprint: Digest,
|
||||||
|
pub version: u64,
|
||||||
|
pub revoked: bool,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub human_account_creation_privilege: bool,
|
||||||
|
pub browser_interaction_required_each_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct DurableState {
|
pub struct DurableState {
|
||||||
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
||||||
|
|
@ -89,6 +105,7 @@ pub struct DurableState {
|
||||||
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
||||||
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
||||||
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
||||||
|
pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait DurableStore {
|
pub trait DurableStore {
|
||||||
|
|
@ -329,6 +346,105 @@ impl Coordinator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn register_agent_public_key(
|
||||||
|
&mut self,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
user: UserId,
|
||||||
|
agent: AgentId,
|
||||||
|
public_key: impl Into<String>,
|
||||||
|
) -> AgentPublicKeyRecord {
|
||||||
|
let key = (tenant.clone(), project.clone(), agent.clone());
|
||||||
|
let version = self
|
||||||
|
.durable
|
||||||
|
.agent_public_keys
|
||||||
|
.get(&key)
|
||||||
|
.map_or(1, |record| record.version.saturating_add(1));
|
||||||
|
let public_key = public_key.into();
|
||||||
|
let public_key_fingerprint = Digest::sha256(&public_key);
|
||||||
|
let record = AgentPublicKeyRecord {
|
||||||
|
tenant: tenant.clone(),
|
||||||
|
project: project.clone(),
|
||||||
|
user: user.clone(),
|
||||||
|
agent: agent.clone(),
|
||||||
|
public_key,
|
||||||
|
public_key_fingerprint: public_key_fingerprint.clone(),
|
||||||
|
version,
|
||||||
|
revoked: false,
|
||||||
|
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
|
||||||
|
human_account_creation_privilege: false,
|
||||||
|
browser_interaction_required_each_run: false,
|
||||||
|
};
|
||||||
|
self.durable.agent_public_keys.insert(key, record.clone());
|
||||||
|
let subject = format!("agent:{tenant}:{project}:{agent}");
|
||||||
|
self.durable.credentials.insert(
|
||||||
|
subject.clone(),
|
||||||
|
CredentialRecord {
|
||||||
|
subject,
|
||||||
|
tenant,
|
||||||
|
project: Some(project),
|
||||||
|
kind: CredentialKind::PublicKey,
|
||||||
|
public_key_fingerprint: Some(public_key_fingerprint),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_agent_public_keys(&self, context: &AuthContext) -> Vec<AgentPublicKeyRecord> {
|
||||||
|
self.durable
|
||||||
|
.agent_public_keys
|
||||||
|
.values()
|
||||||
|
.filter(|record| record.tenant == context.tenant && record.project == context.project)
|
||||||
|
.filter(|record| match &context.actor {
|
||||||
|
Actor::User(user) => &record.user == user,
|
||||||
|
Actor::Agent(agent) => &record.agent == agent,
|
||||||
|
Actor::Node(_) | Actor::Task(_) => false,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_agent_public_key(
|
||||||
|
&mut self,
|
||||||
|
context: &AuthContext,
|
||||||
|
agent: &AgentId,
|
||||||
|
) -> Result<AgentPublicKeyRecord, CoordinatorError> {
|
||||||
|
let key = (
|
||||||
|
context.tenant.clone(),
|
||||||
|
context.project.clone(),
|
||||||
|
agent.clone(),
|
||||||
|
);
|
||||||
|
let record = self
|
||||||
|
.durable
|
||||||
|
.agent_public_keys
|
||||||
|
.get_mut(&key)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoordinatorError::Unauthorized(
|
||||||
|
"agent public key is not registered for this project".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match &context.actor {
|
||||||
|
Actor::User(user) if &record.user == user => {}
|
||||||
|
Actor::User(_) => {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"agent public key is outside the signed-in user scope".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"agent public-key revocation requires a user identity".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.revoked = true;
|
||||||
|
let subject = format!(
|
||||||
|
"agent:{}:{}:{}",
|
||||||
|
context.tenant, context.project, record.agent
|
||||||
|
);
|
||||||
|
self.durable.credentials.remove(&subject);
|
||||||
|
Ok(record.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_process(
|
pub fn start_process(
|
||||||
&mut self,
|
&mut self,
|
||||||
tenant: TenantId,
|
tenant: TenantId,
|
||||||
|
|
@ -660,6 +776,80 @@ mod tests {
|
||||||
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_public_keys_are_project_user_scoped_and_restart_durable() {
|
||||||
|
let mut store = InMemoryDurableStore::default();
|
||||||
|
let mut coordinator = Coordinator::boot(&store, 1);
|
||||||
|
coordinator.upsert_tenant(TenantId::from("tenant"));
|
||||||
|
coordinator.upsert_user(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
UserId::from("user"),
|
||||||
|
CredentialKind::CliDeviceSession,
|
||||||
|
);
|
||||||
|
coordinator.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||||
|
|
||||||
|
let registered = coordinator.register_agent_public_key(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
UserId::from("user"),
|
||||||
|
AgentId::from("agent-ci"),
|
||||||
|
"agent-key-v1",
|
||||||
|
);
|
||||||
|
assert_eq!(registered.version, 1);
|
||||||
|
assert!(!registered.revoked);
|
||||||
|
assert!(!registered.human_account_creation_privilege);
|
||||||
|
assert_eq!(registered.scopes, vec!["project:read", "project:run"]);
|
||||||
|
|
||||||
|
let rotated = coordinator.register_agent_public_key(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
UserId::from("user"),
|
||||||
|
AgentId::from("agent-ci"),
|
||||||
|
"agent-key-v2",
|
||||||
|
);
|
||||||
|
assert_eq!(rotated.version, 2);
|
||||||
|
assert_eq!(rotated.public_key, "agent-key-v2");
|
||||||
|
|
||||||
|
coordinator.persist(&mut store);
|
||||||
|
let mut restarted = Coordinator::boot(&store, 2);
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let listed = restarted.list_agent_public_keys(&context);
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].agent, AgentId::from("agent-ci"));
|
||||||
|
assert_eq!(listed[0].version, 2);
|
||||||
|
|
||||||
|
let foreign_user_context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("other-user")),
|
||||||
|
};
|
||||||
|
assert!(restarted
|
||||||
|
.list_agent_public_keys(&foreign_user_context)
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
let foreign_project_context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("other-project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
assert!(restarted
|
||||||
|
.list_agent_public_keys(&foreign_project_context)
|
||||||
|
.is_empty());
|
||||||
|
|
||||||
|
let revoked = restarted
|
||||||
|
.revoke_agent_public_key(&context, &AgentId::from("agent-ci"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(revoked.revoked);
|
||||||
|
assert!(!restarted
|
||||||
|
.durable
|
||||||
|
.credentials
|
||||||
|
.contains_key("agent:tenant:project:agent-ci"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn node_cannot_claim_process_outside_authorized_scope() {
|
fn node_cannot_claim_process_outside_authorized_scope() {
|
||||||
let store = InMemoryDurableStore::default();
|
let store = InMemoryDurableStore::default();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord,
|
AgentPublicKeyRecord, CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord,
|
||||||
ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord,
|
ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord,
|
||||||
TenantRecord, UserRecord,
|
TenantRecord, UserRecord,
|
||||||
};
|
};
|
||||||
|
|
@ -41,6 +41,11 @@ pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[
|
||||||
durable_record: "credentials",
|
durable_record: "credentials",
|
||||||
restart_surviving: true,
|
restart_surviving: true,
|
||||||
},
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_agent_public_keys",
|
||||||
|
durable_record: "agent public keys",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
PostgresTable {
|
PostgresTable {
|
||||||
name: "disasmer_source_provider_configs",
|
name: "disasmer_source_provider_configs",
|
||||||
durable_record: "source-provider configuration",
|
durable_record: "source-provider configuration",
|
||||||
|
|
@ -148,6 +153,18 @@ impl FallibleDurableStore for PostgresDurableStore {
|
||||||
)? {
|
)? {
|
||||||
state.credentials.insert(record.subject.clone(), record);
|
state.credentials.insert(record.subject.clone(), record);
|
||||||
}
|
}
|
||||||
|
for record in self.query_records::<AgentPublicKeyRecord>(
|
||||||
|
"SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id",
|
||||||
|
)? {
|
||||||
|
state.agent_public_keys.insert(
|
||||||
|
(
|
||||||
|
record.tenant.clone(),
|
||||||
|
record.project.clone(),
|
||||||
|
record.agent.clone(),
|
||||||
|
),
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
}
|
||||||
for record in self.query_records::<SourceProviderConfigRecord>(
|
for record in self.query_records::<SourceProviderConfigRecord>(
|
||||||
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
|
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
|
||||||
)? {
|
)? {
|
||||||
|
|
@ -187,6 +204,7 @@ impl FallibleDurableStore for PostgresDurableStore {
|
||||||
DELETE FROM disasmer_project_permissions;
|
DELETE FROM disasmer_project_permissions;
|
||||||
DELETE FROM disasmer_service_policy_records;
|
DELETE FROM disasmer_service_policy_records;
|
||||||
DELETE FROM disasmer_source_provider_configs;
|
DELETE FROM disasmer_source_provider_configs;
|
||||||
|
DELETE FROM disasmer_agent_public_keys;
|
||||||
DELETE FROM disasmer_credentials;
|
DELETE FROM disasmer_credentials;
|
||||||
DELETE FROM disasmer_node_identities;
|
DELETE FROM disasmer_node_identities;
|
||||||
DELETE FROM disasmer_projects;
|
DELETE FROM disasmer_projects;
|
||||||
|
|
@ -236,6 +254,19 @@ impl FallibleDurableStore for PostgresDurableStore {
|
||||||
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
|
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
for record in state.agent_public_keys.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
&[
|
||||||
|
&record.tenant.as_str(),
|
||||||
|
&record.project.as_str(),
|
||||||
|
&record.user.as_str(),
|
||||||
|
&record.agent.as_str(),
|
||||||
|
&value,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
for ((_, _, provider_key), record) in &state.source_provider_configs {
|
for ((_, _, provider_key), record) in &state.source_provider_configs {
|
||||||
let value = Self::record_value(record)?;
|
let value = Self::record_value(record)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
|
|
@ -305,6 +336,15 @@ CREATE TABLE IF NOT EXISTS disasmer_credentials (
|
||||||
record JSONB NOT NULL
|
record JSONB NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys (
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
record JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, project_id, agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
|
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
|
||||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
|
@ -345,12 +385,13 @@ mod tests {
|
||||||
.map(|table| table.name)
|
.map(|table| table.name)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(names.len(), 8);
|
assert_eq!(names.len(), 9);
|
||||||
assert!(names.contains(&"disasmer_tenants"));
|
assert!(names.contains(&"disasmer_tenants"));
|
||||||
assert!(names.contains(&"disasmer_users"));
|
assert!(names.contains(&"disasmer_users"));
|
||||||
assert!(names.contains(&"disasmer_projects"));
|
assert!(names.contains(&"disasmer_projects"));
|
||||||
assert!(names.contains(&"disasmer_node_identities"));
|
assert!(names.contains(&"disasmer_node_identities"));
|
||||||
assert!(names.contains(&"disasmer_credentials"));
|
assert!(names.contains(&"disasmer_credentials"));
|
||||||
|
assert!(names.contains(&"disasmer_agent_public_keys"));
|
||||||
assert!(names.contains(&"disasmer_source_provider_configs"));
|
assert!(names.contains(&"disasmer_source_provider_configs"));
|
||||||
assert!(names.contains(&"disasmer_service_policy_records"));
|
assert!(names.contains(&"disasmer_service_policy_records"));
|
||||||
assert!(names.contains(&"disasmer_project_permissions"));
|
assert!(names.contains(&"disasmer_project_permissions"));
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,21 @@ use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use disasmer_core::{
|
use disasmer_core::{
|
||||||
Actor, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError, CredentialKind,
|
Actor, AgentId, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError,
|
||||||
DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest, DirectBulkTransferPlan,
|
CredentialKind, DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest,
|
||||||
DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError, LimitKind,
|
DirectBulkTransferPlan, DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError,
|
||||||
NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, PanelEvent,
|
LimitKind, NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId,
|
||||||
PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, Placement, PlacementRequest,
|
PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, Placement,
|
||||||
ProcessId, ProjectId, RateLimit, RendezvousRequest, ResourceLimits, ResourceMeter, Scheduler,
|
PlacementRequest, ProcessId, ProjectId, RateLimit, RendezvousRequest, ResourceLimits,
|
||||||
SourcePreparation, SourceProviderKind, StorageLocation, TaskId, TenantId, TransportError,
|
ResourceMeter, Scheduler, SourcePreparation, SourceProviderKind, StorageLocation, TaskId,
|
||||||
UserId, VfsPath,
|
TenantId, TransportError, UserId, VfsPath,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{Coordinator, CoordinatorError, InMemoryDurableStore, ProjectRecord};
|
use crate::{
|
||||||
|
AgentPublicKeyRecord, Coordinator, CoordinatorError, InMemoryDurableStore, ProjectRecord,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
|
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
|
@ -39,6 +41,31 @@ pub enum CoordinatorRequest {
|
||||||
tenant: String,
|
tenant: String,
|
||||||
actor_user: String,
|
actor_user: String,
|
||||||
},
|
},
|
||||||
|
RegisterAgentPublicKey {
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
user: String,
|
||||||
|
agent: String,
|
||||||
|
public_key: String,
|
||||||
|
},
|
||||||
|
ListAgentPublicKeys {
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
user: String,
|
||||||
|
},
|
||||||
|
RotateAgentPublicKey {
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
user: String,
|
||||||
|
agent: String,
|
||||||
|
public_key: String,
|
||||||
|
},
|
||||||
|
RevokeAgentPublicKey {
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
user: String,
|
||||||
|
agent: String,
|
||||||
|
},
|
||||||
AttachNode {
|
AttachNode {
|
||||||
tenant: String,
|
tenant: String,
|
||||||
project: String,
|
project: String,
|
||||||
|
|
@ -406,6 +433,14 @@ pub enum CoordinatorResponse {
|
||||||
projects: Vec<ProjectRecord>,
|
projects: Vec<ProjectRecord>,
|
||||||
actor: UserId,
|
actor: UserId,
|
||||||
},
|
},
|
||||||
|
AgentPublicKey {
|
||||||
|
record: AgentPublicKeyRecord,
|
||||||
|
actor: UserId,
|
||||||
|
},
|
||||||
|
AgentPublicKeys {
|
||||||
|
records: Vec<AgentPublicKeyRecord>,
|
||||||
|
actor: UserId,
|
||||||
|
},
|
||||||
NodeAttached {
|
NodeAttached {
|
||||||
node: NodeId,
|
node: NodeId,
|
||||||
tenant: TenantId,
|
tenant: TenantId,
|
||||||
|
|
@ -703,6 +738,87 @@ impl CoordinatorService {
|
||||||
actor,
|
actor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
CoordinatorRequest::RegisterAgentPublicKey {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
agent,
|
||||||
|
public_key,
|
||||||
|
}
|
||||||
|
| CoordinatorRequest::RotateAgentPublicKey {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
agent,
|
||||||
|
public_key,
|
||||||
|
} => {
|
||||||
|
let tenant = TenantId::new(tenant);
|
||||||
|
let project = ProjectId::new(project);
|
||||||
|
let actor = UserId::new(user);
|
||||||
|
let agent = AgentId::new(agent);
|
||||||
|
if let Some(existing) = self.coordinator.project(&project) {
|
||||||
|
if existing.tenant != tenant {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"project id is outside the signed-in tenant scope".to_owned(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.coordinator.upsert_tenant(tenant.clone());
|
||||||
|
self.coordinator.upsert_user(
|
||||||
|
tenant.clone(),
|
||||||
|
actor.clone(),
|
||||||
|
CredentialKind::CliDeviceSession,
|
||||||
|
);
|
||||||
|
self.coordinator
|
||||||
|
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||||
|
let record = self.coordinator.register_agent_public_key(
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor.clone(),
|
||||||
|
agent,
|
||||||
|
public_key,
|
||||||
|
);
|
||||||
|
self.coordinator.persist(&mut self.store);
|
||||||
|
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
|
||||||
|
}
|
||||||
|
CoordinatorRequest::ListAgentPublicKeys {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
} => {
|
||||||
|
let tenant = TenantId::new(tenant);
|
||||||
|
let project = ProjectId::new(project);
|
||||||
|
let actor = UserId::new(user);
|
||||||
|
let context = disasmer_core::AuthContext {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor: Actor::User(actor.clone()),
|
||||||
|
};
|
||||||
|
Ok(CoordinatorResponse::AgentPublicKeys {
|
||||||
|
records: self.coordinator.list_agent_public_keys(&context),
|
||||||
|
actor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
CoordinatorRequest::RevokeAgentPublicKey {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
agent,
|
||||||
|
} => {
|
||||||
|
let tenant = TenantId::new(tenant);
|
||||||
|
let project = ProjectId::new(project);
|
||||||
|
let actor = UserId::new(user);
|
||||||
|
let agent = AgentId::new(agent);
|
||||||
|
let context = disasmer_core::AuthContext {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor: Actor::User(actor.clone()),
|
||||||
|
};
|
||||||
|
let record = self.coordinator.revoke_agent_public_key(&context, &agent)?;
|
||||||
|
self.coordinator.persist(&mut self.store);
|
||||||
|
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
|
||||||
|
}
|
||||||
CoordinatorRequest::AttachNode {
|
CoordinatorRequest::AttachNode {
|
||||||
tenant,
|
tenant,
|
||||||
project,
|
project,
|
||||||
|
|
@ -2295,6 +2411,113 @@ mod tests {
|
||||||
assert!(cross_tenant.to_string().contains("tenant scope"));
|
assert!(cross_tenant.to_string().contains("tenant scope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_manages_project_scoped_agent_public_keys() {
|
||||||
|
let mut service = CoordinatorService::new(7);
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
||||||
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
public_key: "agent-key-v1".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected agent public key registration");
|
||||||
|
};
|
||||||
|
assert_eq!(actor, UserId::from("user"));
|
||||||
|
assert_eq!(record.tenant, TenantId::from("tenant"));
|
||||||
|
assert_eq!(record.project, ProjectId::from("project"));
|
||||||
|
assert_eq!(record.user, UserId::from("user"));
|
||||||
|
assert_eq!(record.agent, AgentId::from("agent-ci"));
|
||||||
|
assert_eq!(record.version, 1);
|
||||||
|
assert!(!record.revoked);
|
||||||
|
assert_eq!(record.scopes, vec!["project:read", "project:run"]);
|
||||||
|
assert!(!record.human_account_creation_privilege);
|
||||||
|
assert!(!record.browser_interaction_required_each_run);
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKey { record, .. } = service
|
||||||
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
public_key: "agent-key-v2".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected agent public key rotation by re-add");
|
||||||
|
};
|
||||||
|
assert_eq!(record.version, 2);
|
||||||
|
assert_eq!(record.public_key, "agent-key-v2");
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKeys { records, actor } = service
|
||||||
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected agent public key list");
|
||||||
|
};
|
||||||
|
assert_eq!(actor, UserId::from("user"));
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0].agent, AgentId::from("agent-ci"));
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKeys { records, .. } = service
|
||||||
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "other-user".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected foreign user agent public key list");
|
||||||
|
};
|
||||||
|
assert!(records.is_empty());
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKeys { records, .. } = service
|
||||||
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
||||||
|
tenant: "other-tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected foreign tenant agent public key list");
|
||||||
|
};
|
||||||
|
assert!(records.is_empty());
|
||||||
|
|
||||||
|
let foreign_user_revoke = service
|
||||||
|
.handle_request(CoordinatorRequest::RevokeAgentPublicKey {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "other-user".to_owned(),
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(foreign_user_revoke
|
||||||
|
.to_string()
|
||||||
|
.contains("signed-in user scope"));
|
||||||
|
|
||||||
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
||||||
|
.handle_request(CoordinatorRequest::RevokeAgentPublicKey {
|
||||||
|
tenant: "tenant".to_owned(),
|
||||||
|
project: "project".to_owned(),
|
||||||
|
user: "user".to_owned(),
|
||||||
|
agent: "agent-ci".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected agent public key revocation");
|
||||||
|
};
|
||||||
|
assert_eq!(actor, UserId::from("user"));
|
||||||
|
assert!(record.revoked);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn service_attaches_node_starts_process_and_records_scoped_task_event() {
|
fn service_attaches_node_starts_process_and_records_scoped_task_event() {
|
||||||
let mut service = CoordinatorService::new(7);
|
let mut service = CoordinatorService::new(7);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ expect(
|
||||||
expect(
|
expect(
|
||||||
criteria,
|
criteria,
|
||||||
"future hosted business non-goal",
|
"future hosted business non-goal",
|
||||||
/billing, paid-plan checkout, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/
|
/billing, paid-plan checkout, team\/org management, provider setup wizards, secret-manager UI, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/
|
||||||
);
|
);
|
||||||
|
|
||||||
const lines = criterionLines(criteria);
|
const lines = criterionLines(criteria);
|
||||||
|
|
@ -105,6 +105,7 @@ for (const [name, pattern] of [
|
||||||
["CLI version coverage", /fn top_level_version_is_available\(\)/],
|
["CLI version coverage", /fn top_level_version_is_available\(\)/],
|
||||||
["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/],
|
["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/],
|
||||||
["CLI human output coverage", /fn human_report_is_text_not_json\(\)/],
|
["CLI human output coverage", /fn human_report_is_text_not_json\(\)/],
|
||||||
|
["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/],
|
||||||
["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/],
|
["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/],
|
||||||
["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/],
|
["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/],
|
||||||
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/],
|
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/],
|
||||||
|
|
@ -131,6 +132,11 @@ expect(
|
||||||
"coordinator single active process coverage",
|
"coordinator single active process coverage",
|
||||||
/fn service_rejects_second_active_process_unless_restarting_same_process\(\)/
|
/fn service_rejects_second_active_process_unless_restarting_same_process\(\)/
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
coordinator,
|
||||||
|
"coordinator agent key lifecycle coverage",
|
||||||
|
/fn service_manages_project_scoped_agent_public_keys\(\)/
|
||||||
|
);
|
||||||
|
|
||||||
for (const [name, pattern] of [
|
for (const [name, pattern] of [
|
||||||
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],
|
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ function assertNoUserSessionCredential(surface, text) {
|
||||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
for (const variant of [
|
for (const variant of [
|
||||||
"AttachNode",
|
"AttachNode",
|
||||||
|
"RegisterAgentPublicKey",
|
||||||
|
"ListAgentPublicKeys",
|
||||||
|
"RotateAgentPublicKey",
|
||||||
|
"RevokeAgentPublicKey",
|
||||||
"NodeHeartbeat",
|
"NodeHeartbeat",
|
||||||
"ReportNodeCapabilities",
|
"ReportNodeCapabilities",
|
||||||
"RequestRendezvous",
|
"RequestRendezvous",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue