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,8 +1,9 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use disasmer_core::{
|
||||
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
|
||||
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
Actor, AgentId, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError,
|
||||
EnrollmentGrant, NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId,
|
||||
UserId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
|
@ -78,6 +79,21 @@ pub struct ProjectPermissionRecord {
|
|||
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)]
|
||||
pub struct DurableState {
|
||||
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
||||
|
|
@ -89,6 +105,7 @@ pub struct DurableState {
|
|||
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
||||
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
||||
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
||||
pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>,
|
||||
}
|
||||
|
||||
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(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
|
|
@ -660,6 +776,80 @@ mod tests {
|
|||
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]
|
||||
fn node_cannot_claim_process_outside_authorized_scope() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue