From f35afa833dea534f29e597cbf41713b2297c55be Mon Sep 17 00:00:00 2001 From: Max Malkin Date: Tue, 3 Mar 2026 19:36:21 -0700 Subject: [PATCH] implement editable capabilities on approval page --- crates/registry/src/db/queries.rs | 5 +- crates/registry/src/handlers/grants.rs | 4 + crates/registry/src/services/grant.rs | 43 ++++- services/approval-ui/src/api.ts | 3 + .../approval-ui/src/pages/ApprovalPage.tsx | 167 +++++++++++++++++- 5 files changed, 213 insertions(+), 9 deletions(-) diff --git a/crates/registry/src/db/queries.rs b/crates/registry/src/db/queries.rs index f88e998..3397c03 100644 --- a/crates/registry/src/db/queries.rs +++ b/crates/registry/src/db/queries.rs @@ -347,12 +347,14 @@ pub async fn approve_grant( approved_by: Uuid, approval_nonce: &[u8], approval_signature: &[u8], + granted_capabilities: &[Capability], ) -> Result { let result = sqlx::query( r#" UPDATE capability_grants SET status = 'approved', approved_by = $2, approval_nonce = $3, - approval_signature = $4, decided_at = NOW(), updated_at = NOW() + approval_signature = $4, granted_capabilities = $5, + decided_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'pending' "#, ) @@ -360,6 +362,7 @@ pub async fn approve_grant( .bind(approved_by) .bind(approval_nonce) .bind(approval_signature) + .bind(sqlx::types::Json(granted_capabilities)) .execute(pool) .await?; diff --git a/crates/registry/src/handlers/grants.rs b/crates/registry/src/handlers/grants.rs index ae4dbf2..cee27d2 100644 --- a/crates/registry/src/handlers/grants.rs +++ b/crates/registry/src/handlers/grants.rs @@ -81,6 +81,9 @@ pub struct ApproveGrantRequest { /// Approval signature (WebAuthn signature). #[serde(with = "hex_serde")] pub approval_signature: Vec, + /// Optional subset of capabilities to grant. If omitted, all requested capabilities are granted. + #[serde(default)] + pub granted_capabilities: Option>, } /// Request a new grant. @@ -159,6 +162,7 @@ pub async fn approve_grant( req.approved_by, &req.approval_nonce, &req.approval_signature, + req.granted_capabilities, ) .await?; diff --git a/crates/registry/src/services/grant.rs b/crates/registry/src/services/grant.rs index f13f983..f032cdd 100644 --- a/crates/registry/src/services/grant.rs +++ b/crates/registry/src/services/grant.rs @@ -101,6 +101,7 @@ impl GrantService { approved_by: Uuid, approval_nonce: &[u8], approval_signature: &[u8], + granted_capabilities: Option>, ) -> Result { // Get the grant first let grant = self @@ -118,6 +119,31 @@ impl GrantService { return Err(RegistryError::GrantExpired); } + // Resolve the capability subset to persist + let resolved_capabilities = if let Some(caps) = granted_capabilities { + if caps.is_empty() { + return Err(RegistryError::InvalidCapability( + "must grant at least one capability".to_string(), + )); + } + // Verify every requested cap appears in the original request + for cap in &caps { + let cap_type = capability_type_key(cap); + let in_original = grant + .requested_capabilities + .iter() + .any(|r| capability_type_key(r) == cap_type); + if !in_original { + return Err(RegistryError::InvalidCapability(format!( + "cannot grant capability not in original request: {cap_type}" + ))); + } + } + caps + } else { + grant.requested_capabilities.clone() + }; + // Approve in database let approved = db::approve_grant( self.db.primary(), @@ -125,6 +151,7 @@ impl GrantService { approved_by, approval_nonce, approval_signature, + &resolved_capabilities, ) .await?; @@ -132,11 +159,11 @@ impl GrantService { return Err(RegistryError::GrantNotPending); } - // Return updated grant - // Note: approval_assertion would need full WebAuthn data to populate + // Return updated grant with the approved capability subset let updated = CapabilityGrant { status: GrantStatus::Approved, approved_at: Some(Utc::now()), + requested_capabilities: resolved_capabilities, ..grant }; @@ -225,3 +252,15 @@ impl GrantService { }) } } + +/// Return a stable string key representing the type and resource of a capability, +/// used to check whether a requested capability is a subset of the original request. +fn capability_type_key(cap: &Capability) -> String { + match cap { + Capability::Read { resource, .. } => format!("read:{resource}"), + Capability::Write { resource, .. } => format!("write:{resource}"), + Capability::Delete { resource, .. } => format!("delete:{resource}"), + Capability::Transact { resource, .. } => format!("transact:{resource}"), + Capability::Custom { namespace, name, .. } => format!("custom:{namespace}:{name}"), + } +} diff --git a/services/approval-ui/src/api.ts b/services/approval-ui/src/api.ts index 5f36f45..a69225d 100644 --- a/services/approval-ui/src/api.ts +++ b/services/approval-ui/src/api.ts @@ -7,6 +7,7 @@ import type { AuditEvent, ApprovalAssertion, ApiError, + Capability, } from "./types"; const REGISTRY_URL = "http://localhost:8080"; @@ -114,6 +115,7 @@ export async function approveGrant( approvedBy: string, approvalNonce: string, approvalSignature: string, + grantedCapabilities: Capability[], ): Promise { await request(`/v1/grants/${grantId}/approve`, { method: "POST", @@ -121,6 +123,7 @@ export async function approveGrant( approved_by: approvedBy, approval_nonce: approvalNonce, approval_signature: approvalSignature, + granted_capabilities: grantedCapabilities, }), }); } diff --git a/services/approval-ui/src/pages/ApprovalPage.tsx b/services/approval-ui/src/pages/ApprovalPage.tsx index 6299592..ccb2b98 100644 --- a/services/approval-ui/src/pages/ApprovalPage.tsx +++ b/services/approval-ui/src/pages/ApprovalPage.tsx @@ -24,6 +24,12 @@ export function ApprovalPage() { const { navigate } = useRouter(); const [state, setState] = useState({ type: "loading" }); const [denyReason, setDenyReason] = useState(""); + const [enabledIndices, setEnabledIndices] = useState>( + new Set(), + ); + const [pendingRemoveIndex, setPendingRemoveIndex] = useState( + null, + ); useEffect(() => { loadGrant(); @@ -55,6 +61,11 @@ export function ApprovalPage() { }); return; } + // Initialize all capabilities as enabled + setEnabledIndices( + new Set(grant.requested_capabilities.map((_, i) => i)), + ); + setPendingRemoveIndex(null); setState({ type: "loaded", grant }); } catch (err) { setState({ @@ -68,8 +79,38 @@ export function ApprovalPage() { } } + function handleRemove(grant: GrantRequest, index: number) { + const cap = grant.requested_capabilities[index]; + if (cap === undefined) return; + if (requiresTwoStep(cap)) { + setPendingRemoveIndex(index); + } else { + setEnabledIndices((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + } + } + + function handleConfirmRemove(index: number) { + setEnabledIndices((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + setPendingRemoveIndex(null); + } + + function handleRestore(index: number) { + setEnabledIndices((prev) => new Set([...prev, index])); + } + function handleApproveClick(grant: GrantRequest) { - const summary = getCapabilitySummary(grant.requested_capabilities); + const granted = grant.requested_capabilities.filter((_, i) => + enabledIndices.has(i), + ); + const summary = getCapabilitySummary(granted); if (summary.hasHighRisk) { setState({ type: "confirming", grant, step: 1 }); } else { @@ -98,11 +139,16 @@ export function ApprovalPage() { b.toString(16).padStart(2, "0"), ).join(""); + const grantedCapabilities = grant.requested_capabilities.filter( + (_, i) => enabledIndices.has(i), + ); + await approveGrant( grant.grant_id, grant.human_principal_id, nonce, signature, + grantedCapabilities, ); setState({ type: "success", action: "approved" }); } catch (err) { @@ -285,7 +331,11 @@ export function ApprovalPage() { const grant = state.grant; const isConfirming = state.type === "confirming"; const confirmStep = isConfirming ? state.step : 0; - const summary = getCapabilitySummary(grant.requested_capabilities); + const grantedCapabilities = grant.requested_capabilities.filter((_, i) => + enabledIndices.has(i), + ); + const summary = getCapabilitySummary(grantedCapabilities); + const allRemoved = grantedCapabilities.length === 0; return ( @@ -327,9 +377,27 @@ export function ApprovalPage() {
{grant.requested_capabilities.map((cap, idx) => ( - + handleRemove(grant, idx)} + onRestore={() => handleRestore(idx)} + onConfirmRemove={() => + handleConfirmRemove(idx) + } + onCancelRemove={() => + setPendingRemoveIndex(null) + } + /> ))}
+ {allRemoved && ( +

+ All permissions removed — use Deny instead +

+ )} {/* Behavioral constraints */} @@ -385,7 +453,12 @@ export function ApprovalPage() { @@ -444,7 +517,7 @@ export function ApprovalPage() { You are granting access to:

- {grant.requested_capabilities + {grantedCapabilities .filter(requiresTwoStep) .map((cap, idx) => (
void; + onRestore: () => void; + onConfirmRemove: () => void; + onCancelRemove: () => void; +}) { const risk = getCapabilityRiskLevel(capability); const needsTwoStep = requiresTwoStep(capability); @@ -560,6 +649,66 @@ function CapabilityRow({ capability }: { capability: Capability }) { high: "bg-red", }; + if (!enabled) { + return ( +
+
+ + {capabilityToHumanReadable(capability)} + + + REMOVED + + +
+ ); + } + + if (isPendingRemove) { + return ( +
+
+
+ + {capabilityToHumanReadable(capability)} + + {needsTwoStep && ( + + 2-STEP + + )} +
+
+

+ Remove this permission? The agent will be denied this + capability. +

+
+ + +
+
+
+ ); + } + return (
@@ -571,6 +720,12 @@ function CapabilityRow({ capability }: { capability: Capability }) { 2-STEP )} +
); }