Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion crates/registry/src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,19 +347,22 @@ pub async fn approve_grant(
approved_by: Uuid,
approval_nonce: &[u8],
approval_signature: &[u8],
granted_capabilities: &[Capability],
) -> Result<bool> {
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'
"#,
)
.bind(grant_id.as_uuid())
.bind(approved_by)
.bind(approval_nonce)
.bind(approval_signature)
.bind(sqlx::types::Json(granted_capabilities))
.execute(pool)
.await?;

Expand Down
4 changes: 4 additions & 0 deletions crates/registry/src/handlers/grants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ pub struct ApproveGrantRequest {
/// Approval signature (WebAuthn signature).
#[serde(with = "hex_serde")]
pub approval_signature: Vec<u8>,
/// Optional subset of capabilities to grant. If omitted, all requested capabilities are granted.
#[serde(default)]
pub granted_capabilities: Option<Vec<Capability>>,
}

/// Request a new grant.
Expand Down Expand Up @@ -159,6 +162,7 @@ pub async fn approve_grant(
req.approved_by,
&req.approval_nonce,
&req.approval_signature,
req.granted_capabilities,
)
.await?;

Expand Down
43 changes: 41 additions & 2 deletions crates/registry/src/services/grant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ impl GrantService {
approved_by: Uuid,
approval_nonce: &[u8],
approval_signature: &[u8],
granted_capabilities: Option<Vec<Capability>>,
) -> Result<CapabilityGrant> {
// Get the grant first
let grant = self
Expand All @@ -118,25 +119,51 @@ 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(),
grant_id,
approved_by,
approval_nonce,
approval_signature,
&resolved_capabilities,
)
.await?;

if !approved {
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
};

Expand Down Expand Up @@ -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}"),
}
}
3 changes: 3 additions & 0 deletions services/approval-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AuditEvent,
ApprovalAssertion,
ApiError,
Capability,
} from "./types";

const REGISTRY_URL = "http://localhost:8080";
Expand Down Expand Up @@ -114,13 +115,15 @@ export async function approveGrant(
approvedBy: string,
approvalNonce: string,
approvalSignature: string,
grantedCapabilities: Capability[],
): Promise<void> {
await request(`/v1/grants/${grantId}/approve`, {
method: "POST",
body: JSON.stringify({
approved_by: approvedBy,
approval_nonce: approvalNonce,
approval_signature: approvalSignature,
granted_capabilities: grantedCapabilities,
}),
});
}
Expand Down
167 changes: 161 additions & 6 deletions services/approval-ui/src/pages/ApprovalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export function ApprovalPage() {
const { navigate } = useRouter();
const [state, setState] = useState<PageState>({ type: "loading" });
const [denyReason, setDenyReason] = useState("");
const [enabledIndices, setEnabledIndices] = useState<Set<number>>(
new Set(),
);
const [pendingRemoveIndex, setPendingRemoveIndex] = useState<number | null>(
null,
);

useEffect(() => {
loadGrant();
Expand Down Expand Up @@ -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({
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<Shell>
Expand Down Expand Up @@ -327,9 +377,27 @@ export function ApprovalPage() {
</SectionLabel>
<div className="border border-border divide-y divide-border stagger-children">
{grant.requested_capabilities.map((cap, idx) => (
<CapabilityRow key={idx} capability={cap} />
<CapabilityRow
key={idx}
capability={cap}
enabled={enabledIndices.has(idx)}
isPendingRemove={pendingRemoveIndex === idx}
onRemove={() => handleRemove(grant, idx)}
onRestore={() => handleRestore(idx)}
onConfirmRemove={() =>
handleConfirmRemove(idx)
}
onCancelRemove={() =>
setPendingRemoveIndex(null)
}
/>
))}
</div>
{allRemoved && (
<p className="mt-2 text-xs font-mono text-text-muted text-right">
All permissions removed — use Deny instead
</p>
)}
</div>

{/* Behavioral constraints */}
Expand Down Expand Up @@ -385,7 +453,12 @@ export function ApprovalPage() {
</div>
<button
onClick={() => handleApproveClick(grant)}
className="px-6 py-3 bg-amber text-surface font-mono text-sm font-medium tracking-wide hover:bg-amber-dim transition-colors"
disabled={allRemoved}
className={`px-6 py-3 font-mono text-sm font-medium tracking-wide transition-colors ${
allRemoved
? "bg-panel border border-border text-text-muted cursor-not-allowed"
: "bg-amber text-surface hover:bg-amber-dim"
}`}
>
APPROVE
</button>
Expand Down Expand Up @@ -444,7 +517,7 @@ export function ApprovalPage() {
You are granting access to:
</p>
<div className="border border-red-dim bg-red-glow divide-y divide-red-dim/50 mb-4">
{grant.requested_capabilities
{grantedCapabilities
.filter(requiresTwoStep)
.map((cap, idx) => (
<div
Expand Down Expand Up @@ -550,7 +623,23 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}

function CapabilityRow({ capability }: { capability: Capability }) {
function CapabilityRow({
capability,
enabled,
isPendingRemove,
onRemove,
onRestore,
onConfirmRemove,
onCancelRemove,
}: {
capability: Capability;
enabled: boolean;
isPendingRemove: boolean;
onRemove: () => void;
onRestore: () => void;
onConfirmRemove: () => void;
onCancelRemove: () => void;
}) {
const risk = getCapabilityRiskLevel(capability);
const needsTwoStep = requiresTwoStep(capability);

Expand All @@ -560,6 +649,66 @@ function CapabilityRow({ capability }: { capability: Capability }) {
high: "bg-red",
};

if (!enabled) {
return (
<div className="flex items-center gap-3 px-4 py-3 bg-panel opacity-50">
<div className={`w-1.5 h-1.5 ${riskColors[risk]} shrink-0`} />
<span className="text-sm text-text-secondary flex-1 line-through">
{capabilityToHumanReadable(capability)}
</span>
<span className="font-mono text-[10px] tracking-wide text-red border border-red/30 px-2 py-0.5">
REMOVED
</span>
<button
onClick={onRestore}
className="font-mono text-[10px] tracking-wide text-text-muted border border-border px-2 py-0.5 hover:border-text-secondary hover:text-text-secondary transition-colors"
>
RESTORE
</button>
</div>
);
}

if (isPendingRemove) {
return (
<div className="bg-panel border-l-2 border-red">
<div className="flex items-center gap-3 px-4 py-3">
<div
className={`w-1.5 h-1.5 ${riskColors[risk]} shrink-0`}
/>
<span className="text-sm text-text-primary flex-1">
{capabilityToHumanReadable(capability)}
</span>
{needsTwoStep && (
<span className="font-mono text-[10px] tracking-wide text-red border border-red-dim px-2 py-0.5 bg-red-glow">
2-STEP
</span>
)}
</div>
<div className="px-4 pb-3 flex items-center justify-between gap-3">
<p className="text-xs text-text-secondary">
Remove this permission? The agent will be denied this
capability.
</p>
<div className="flex gap-2 shrink-0">
<button
onClick={onCancelRemove}
className="font-mono text-[10px] tracking-wide text-text-muted border border-border px-2 py-1 hover:border-text-secondary hover:text-text-secondary transition-colors"
>
CANCEL
</button>
<button
onClick={onConfirmRemove}
className="font-mono text-[10px] tracking-wide text-red border border-red-dim px-2 py-1 hover:bg-red-glow transition-colors"
>
CONFIRM REMOVE
</button>
</div>
</div>
</div>
);
}

return (
<div className="flex items-center gap-3 px-4 py-3 bg-panel hover:bg-panel-hover transition-colors">
<div className={`w-1.5 h-1.5 ${riskColors[risk]} shrink-0`} />
Expand All @@ -571,6 +720,12 @@ function CapabilityRow({ capability }: { capability: Capability }) {
2-STEP
</span>
)}
<button
onClick={onRemove}
className="font-mono text-[10px] tracking-wide text-text-muted border border-border px-2 py-0.5 hover:border-red hover:text-red transition-colors"
>
REMOVE
</button>
</div>
);
}
Loading