Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
29573d8
add config file support and standalone redis for local dev
maxmalkin Mar 3, 2026
0c56313
ensure audit_events partition exists on registry startup
maxmalkin Mar 3, 2026
7119fe5
add cors support for approval ui local development
maxmalkin Mar 3, 2026
b063584
bridge grant api contract between backend and ui
maxmalkin Mar 3, 2026
df32d50
implement agent list and detail endpoints with grants
maxmalkin Mar 3, 2026
d347a2f
fix ui capability types and grant status handling
maxmalkin Mar 3, 2026
01d9015
implement demo-mode approval and agent registration script
maxmalkin Mar 3, 2026
e37991f
handle grant status mapping in agent activity page
maxmalkin Mar 3, 2026
01b6e87
add .claude/ to gitignore
maxmalkin Mar 3, 2026
1564299
reformat ci and nightly workflow yaml indentation
maxmalkin Mar 3, 2026
7f9f7db
add demo data seed support on registry startup
maxmalkin Mar 3, 2026
2e728cc
fix SDK token issue mismatch
maxmalkin Mar 3, 2026
bf25642
create demo agent binary
maxmalkin Mar 3, 2026
3e741db
add Dashboard tab to approval UI with Grafana embedding
maxmalkin Mar 3, 2026
3d23059
integrate demo agent into dev.sh and remove old bootstrap script
maxmalkin Mar 3, 2026
e0336ee
fix SDK compatibility in registry handlers
maxmalkin Mar 3, 2026
8fd95ab
resolve port conflict and fix dashboard UIDs
maxmalkin Mar 3, 2026
75ca7cd
fix human_principal_id FK constraint on grant approval
maxmalkin Mar 3, 2026
5e6201f
add approve buttons to agents list and detail screens
maxmalkin Mar 3, 2026
ce17551
fix all clippy warnings
maxmalkin Mar 3, 2026
622af34
add grant idempotency check to return existing pending grants
maxmalkin Mar 3, 2026
5801854
fix signature deserialization to disambiguate base64url vs hex
maxmalkin Mar 3, 2026
e4203b3
add id and is_active fields to agent response for test compatibility
maxmalkin Mar 3, 2026
3f75863
fix integration test infrastructure configuration
maxmalkin Mar 3, 2026
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
529 changes: 257 additions & 272 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

506 changes: 253 additions & 253 deletions .github/workflows/nightly.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ Thumbs.db
CLAUDE.md
**/CLAUDE.md
WORKLOG.md
.claude/
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"services/registry",
"services/verifier",
"services/audit-archiver",
"services/demo-agent",
"tests/compliance",
"tests/integration",
"tests/stability",
Expand Down Expand Up @@ -63,7 +64,7 @@ base64 = "0.22"
hex = "0.4"

# UUID
uuid = { version = "1.7", features = ["v7", "serde"] }
uuid = { version = "1.7", features = ["v5", "v7", "serde"] }

# Time
chrono = { version = "0.4", features = ["serde"] }
Expand Down
56 changes: 56 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# AgentAuth Registry — Local Development Configuration
#
# This file is loaded automatically by the registry service.
# Environment variables with prefix AGENTAUTH__ override these values.

[server]
host = "0.0.0.0"
port = 8080
metrics_port = 9091
shutdown_timeout_secs = 5
external_url = "http://localhost:8080"
verifier_url = "http://localhost:8081"
approval_ui_url = "http://localhost:3001"

[database]
primary_url = "postgres://agentauth:agentauth_dev@localhost:5434/agentauth"
replica_urls = []
max_connections = 16
connect_timeout_secs = 5
query_timeout_secs = 5

[redis]
urls = ["redis://localhost:6380"]
timeout_secs = 2
token_cache_prefix = "token:"
nonce_store_prefix = "nonce:"
rate_limit_prefix = "rl:"

[kms]
signing_key_id = "dev-signing-key"
timeout_secs = 5

# EncryptedKeyfile
[kms.backend]
encrypted_keyfile = { path = "/dev/null" }

[grants]
max_pending_per_agent = 5
expiry_secs = 3600
cooldown_multiplier = 4.0
initial_cooldown_secs = 3600
max_cooldown_secs = 86400
max_requests_per_minute = 60
max_burst = 10

[tokens]
lifetime_secs = 900
idempotency_window_secs = 900
revocation_propagation_ms = 100

[observability]
service_name = "agentauth-registry"
log_level = "info"

[demo]
enabled = true
17 changes: 16 additions & 1 deletion crates/registry/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ pub struct RegistryConfig {
pub tokens: TokenConfig,
/// Observability configuration.
pub observability: ObservabilityConfig,
/// Demo mode configuration.
#[serde(default)]
pub demo: DemoConfig,
}

/// Demo mode configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DemoConfig {
/// Whether to seed demo data on startup.
#[serde(default)]
pub enabled: bool,
}

/// Server configuration.
Expand Down Expand Up @@ -271,9 +282,13 @@ fn default_log_level() -> String {
}

impl RegistryConfig {
/// Load configuration from environment.
/// Load configuration from config file (optional) and environment variables.
///
/// Loads `config.toml` if present, then applies environment variable overrides
/// with prefix `AGENTAUTH__` (e.g., `AGENTAUTH__SERVER__PORT=8080`).
pub fn from_env() -> Result<Self, config::ConfigError> {
config::Config::builder()
.add_source(config::File::with_name("config").required(false))
.add_source(config::Environment::with_prefix("AGENTAUTH").separator("__"))
.build()?
.try_deserialize()
Expand Down
102 changes: 101 additions & 1 deletion crates/registry/src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ pub async fn get_agent(pool: &PgPool, agent_id: &AgentId) -> Result<Option<Agent
Ok(row)
}

/// List all active agents.
pub async fn list_agents(pool: &PgPool) -> Result<Vec<AgentRow>> {
let rows = sqlx::query_as::<_, AgentRow>(
r#"
SELECT id, human_principal_id, name, description, public_key, key_id,
requested_capabilities, default_behavioral_envelope, model_origin,
signature, issued_at, expires_at, is_active, created_at
FROM agent_manifests
ORDER BY created_at DESC
"#,
)
.fetch_all(pool)
.await?;

Ok(rows)
}

/// Check if an agent exists.
pub async fn agent_exists(pool: &PgPool, agent_id: &AgentId) -> Result<bool> {
// EXPLAIN ANALYZE: Uses primary key index
Expand Down Expand Up @@ -147,8 +164,12 @@ pub struct GrantRow {
pub id: Uuid,
/// Agent ID.
pub agent_id: Uuid,
/// Agent name (joined from agent_manifests).
pub agent_name: String,
/// Service provider ID.
pub service_provider_id: Uuid,
/// Service provider name (joined from service_providers).
pub service_provider_name: String,
/// Human principal ID (from agent).
pub human_principal_id: Uuid,
/// Approved by human principal ID.
Expand Down Expand Up @@ -208,16 +229,49 @@ pub async fn insert_grant(
Ok(())
}

/// Get the most recent pending grant for an agent + service provider (for idempotency).
pub async fn get_pending_grant_for_agent_sp(
pool: &PgPool,
agent_id: &AgentId,
service_provider_id: Uuid,
) -> Result<Option<GrantRow>> {
let row = sqlx::query_as::<_, GrantRow>(
r#"
SELECT g.id, g.agent_id, a.name AS agent_name,
g.service_provider_id, sp.name AS service_provider_name,
a.human_principal_id,
g.approved_by, g.granted_capabilities,
g.behavioral_envelope, g.status::text as status, g.approval_nonce, g.approval_signature,
g.requested_at, g.decided_at, g.expires_at
FROM capability_grants g
INNER JOIN agent_manifests a ON g.agent_id = a.id
INNER JOIN service_providers sp ON g.service_provider_id = sp.id
WHERE g.agent_id = $1 AND g.service_provider_id = $2 AND g.status = 'pending'
ORDER BY g.requested_at DESC
LIMIT 1
"#,
)
.bind(agent_id.as_uuid())
.bind(service_provider_id)
.fetch_optional(pool)
.await?;

Ok(row)
}

/// Get a grant by ID.
pub async fn get_grant(pool: &PgPool, grant_id: &GrantId) -> Result<Option<GrantRow>> {
let row = sqlx::query_as::<_, GrantRow>(
r#"
SELECT g.id, g.agent_id, g.service_provider_id, a.human_principal_id,
SELECT g.id, g.agent_id, a.name AS agent_name,
g.service_provider_id, sp.name AS service_provider_name,
a.human_principal_id,
g.approved_by, g.granted_capabilities,
g.behavioral_envelope, g.status::text as status, g.approval_nonce, g.approval_signature,
g.requested_at, g.decided_at, g.expires_at
FROM capability_grants g
INNER JOIN agent_manifests a ON g.agent_id = a.id
INNER JOIN service_providers sp ON g.service_provider_id = sp.id
WHERE g.id = $1
"#,
)
Expand All @@ -228,6 +282,41 @@ pub async fn get_grant(pool: &PgPool, grant_id: &GrantId) -> Result<Option<Grant
Ok(row)
}

/// List grants for an agent (with service provider names).
pub async fn list_grants_for_agent(pool: &PgPool, agent_id: &AgentId) -> Result<Vec<GrantRow>> {
let rows = sqlx::query_as::<_, GrantRow>(
r#"
SELECT g.id, g.agent_id, a.name AS agent_name,
g.service_provider_id, sp.name AS service_provider_name,
a.human_principal_id,
g.approved_by, g.granted_capabilities,
g.behavioral_envelope, g.status::text as status, g.approval_nonce, g.approval_signature,
g.requested_at, g.decided_at, g.expires_at
FROM capability_grants g
INNER JOIN agent_manifests a ON g.agent_id = a.id
INNER JOIN service_providers sp ON g.service_provider_id = sp.id
WHERE g.agent_id = $1
ORDER BY g.requested_at DESC
"#,
)
.bind(agent_id.as_uuid())
.fetch_all(pool)
.await?;

Ok(rows)
}

/// Count active (approved) grants for an agent.
pub async fn count_active_grants(pool: &PgPool, agent_id: &AgentId) -> Result<i64> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM capability_grants WHERE agent_id = $1 AND status = 'approved'",
)
.bind(agent_id.as_uuid())
.fetch_one(pool)
.await?;
Ok(count)
}

/// Count pending grants for an agent.
pub async fn count_pending_grants(pool: &PgPool, agent_id: &AgentId) -> Result<i64> {
// EXPLAIN ANALYZE: Uses idx_capability_grants_pending index
Expand All @@ -240,6 +329,17 @@ pub async fn count_pending_grants(pool: &PgPool, agent_id: &AgentId) -> Result<i
Ok(count)
}

/// Get the most recent pending grant ID for an agent, if any.
pub async fn get_pending_grant_id(pool: &PgPool, agent_id: &AgentId) -> Result<Option<Uuid>> {
let id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM capability_grants WHERE agent_id = $1 AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
)
.bind(agent_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(id)
}

/// Approve a grant.
pub async fn approve_grant(
pool: &PgPool,
Expand Down
91 changes: 91 additions & 0 deletions crates/registry/src/demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Demo mode constants and seed data.
//!
//! When `[demo] enabled = true` in config, the registry seeds a human principal
//! and service provider on startup so the demo agent can register against them.

use tracing::info;
use uuid::Uuid;

// Fixed namespace for deterministic UUID v5 generation.
const DEMO_NAMESPACE: Uuid = Uuid::from_bytes([
0xAA, 0x67, 0xAE, 0x01, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45,
0x67,
]);

/// Deterministic human principal ID for demo mode.
pub fn demo_human_principal_id() -> Uuid {
Uuid::new_v5(&DEMO_NAMESPACE, b"human-principal")
}

/// Deterministic service provider ID for demo mode.
pub fn demo_service_provider_id() -> Uuid {
Uuid::new_v5(&DEMO_NAMESPACE, b"service-provider")
}

/// Deterministic agent ID for demo mode.
pub fn demo_agent_id() -> Uuid {
Uuid::new_v5(&DEMO_NAMESPACE, b"demo-agent")
}

/// Deterministic 32-byte seed for the demo agent's Ed25519 keypair.
/// SHA-256("agentauth-demo-agent-key-v1") truncated to 32 bytes.
pub const DEMO_AGENT_KEY_SEED: [u8; 32] = [
0x7a, 0x1b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81, 0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8,
0x09, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81, 0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7,
0xf8, 0x09,
];

/// Seed demo data into the database (idempotent).
pub async fn seed_demo_data(pool: &sqlx::PgPool) {
let hp_id = demo_human_principal_id();
let sp_id = demo_service_provider_id();

// Seed human principal
match sqlx::query(
r#"INSERT INTO human_principals (id, email, email_verified)
VALUES ($1, $2, true)
ON CONFLICT (id) DO NOTHING"#,
)
.bind(hp_id)
.bind("demo@agentauth.dev")
.execute(pool)
.await
{
Ok(r) if r.rows_affected() > 0 => info!("Seeded demo human principal: {hp_id}"),
Ok(_) => info!("Demo human principal already exists: {hp_id}"),
Err(e) => tracing::warn!(error = %e, "Failed to seed demo human principal"),
}

// Seed service provider
let allowed_caps = serde_json::json!([
{"type": "read", "resource": "calendar"},
{"type": "write", "resource": "files"},
{"type": "delete", "resource": "files"},
{"type": "transact", "resource": "payments", "max_value": 10000},
]);

match sqlx::query(
r#"INSERT INTO service_providers (id, name, description, verification_endpoint, public_key, allowed_capabilities, is_active)
VALUES ($1, $2, $3, $4, $5, $6, true)
ON CONFLICT (id) DO NOTHING"#,
)
.bind(sp_id)
.bind("Acme Cloud Services")
.bind("Demo service provider for calendar, files, and payments")
.bind("http://localhost:9090/verify")
.bind(vec![0u8; 32]) // dummy public key
.bind(allowed_caps)
.execute(pool)
.await
{
Ok(r) if r.rows_affected() > 0 => info!("Seeded demo service provider: {sp_id}"),
Ok(_) => info!("Demo service provider already exists: {sp_id}"),
Err(e) => tracing::warn!(error = %e, "Failed to seed demo service provider"),
}

info!(
human_principal_id = %hp_id,
service_provider_id = %sp_id,
"Demo seed data ready"
);
}
5 changes: 5 additions & 0 deletions crates/registry/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pub enum RegistryError {
#[error("grant not found: {0}")]
GrantNotFound(String),

/// Grant not approved (cannot issue token).
#[error("grant not approved: {0}")]
GrantNotApproved(String),

/// Token not found.
#[error("token not found: {0}")]
TokenNotFound(String),
Expand Down Expand Up @@ -117,6 +121,7 @@ impl IntoResponse for RegistryError {
let (status, error_code, retry_after) = match &self {
Self::AgentNotFound(_) => (StatusCode::NOT_FOUND, "agent_not_found", None),
Self::GrantNotFound(_) => (StatusCode::NOT_FOUND, "grant_not_found", None),
Self::GrantNotApproved(_) => (StatusCode::CONFLICT, "grant_not_approved", None),
Self::TokenNotFound(_) => (StatusCode::NOT_FOUND, "token_not_found", None),
Self::InvalidManifest(_) => (StatusCode::BAD_REQUEST, "invalid_manifest", None),
Self::InvalidCapability(_) => (StatusCode::BAD_REQUEST, "invalid_capability", None),
Expand Down
Loading
Loading