Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/authsome/server/routes/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async def list_audit_events(
request: Request,
limit: int = 50,
cursor: str | None = None,
identity: str | None = None,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> dict[str, Any]:
effective_principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
Expand All @@ -28,6 +29,7 @@ async def list_audit_events(
page = await request.app.state.audit_log.query_events(
limit=limit,
principal_id=effective_principal_id,
identity=identity,
cursor=cursor,
)
except ValueError as exc:
Expand Down
45 changes: 45 additions & 0 deletions src/authsome/server/routes/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel

from authsome.identity.principal import ClaimStatus, PrincipalRole
from authsome.server.analytics import capture_event
from authsome.server.credential_service import CredentialService
from authsome.server.routes._deps import get_daemon_or_browser_auth_service
from authsome.server.schemas import AgentDetailResponse
from authsome.server.store.repositories import IdentityRegistrationError

router = APIRouter(prefix="/identities", tags=["identities"])
Expand Down Expand Up @@ -63,6 +65,49 @@ async def resolve_identity_by_did(did: str, request: Request) -> dict[str, str]:
return {"identity": registration.handle, "did": registration.did}


@router.get("/{handle}/detail")
async def get_identity_detail(
handle: str,
request: Request,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> AgentDetailResponse:
registration = await request.app.state.store.identity_registry.resolve(handle)
if registration is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")

claim = await request.app.state.store.identity_claims.resolve(handle)
if auth.principal_role != PrincipalRole.ADMIN and (claim is None or claim.principal_id != auth.principal_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")

principal_email = None
if claim is not None:
principal = await request.app.state.store.principals.get(claim.principal_id)
principal_email = principal.email if principal else None

if claim is None:
registration_status = "claim_required"
elif claim.claim_status == ClaimStatus.ACCEPTED:
registration_status = "claimed"
else:
registration_status = claim.claim_status.value

active_identity = (
auth.identity or getattr(request.state, "identity", None) or getattr(request.state, "ui_identity", None)
)
return AgentDetailResponse(
handle=registration.handle,
did=registration.did,
registration_status=registration_status,
claim_status=claim.claim_status.value if claim else None,
principal_id=claim.principal_id if claim else None,
principal_email=principal_email,
is_active=registration.handle == active_identity,
created_at=registration.created_at,
updated_at=registration.updated_at,
claimed_at=claim.created_at if claim else None,
)


@router.get("/{handle}")
async def get_identity_status(handle: str, request: Request) -> dict[str, str]:
registration_status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle)
Expand Down
13 changes: 13 additions & 0 deletions src/authsome/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,16 @@ class ConnectionDetailResponse(BaseModel):
can_set_default: bool = False
can_set_global: bool = False
is_global: bool = False


class AgentDetailResponse(BaseModel):
handle: str
did: str
registration_status: str
claim_status: str | None = None
principal_id: str | None = None
principal_email: str | None = None
is_active: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
claimed_at: datetime | None = None
6 changes: 6 additions & 0 deletions src/authsome/server/store/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ async def query_events(
*,
limit: int = 50,
principal_id: str | None = None,
identity: str | None = None,
cursor: str | None = None,
) -> AuditEventPage:
bounded_limit = min(max(limit, 1), 500)
Expand All @@ -148,6 +149,9 @@ async def query_events(
if principal_id is not None:
conditions.append("principal_id = ?")
params.append(principal_id)
if identity is not None:
conditions.append("identity = ?")
params.append(identity)

if cursor:
cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor)
Expand Down Expand Up @@ -334,12 +338,14 @@ async def query_events(
*,
limit: int = 50,
principal_id: str | None = None,
identity: str | None = None,
cursor: str | None = None,
) -> AuditEventPage:
await self.async_force_flush()
return await self._registry.query_events(
limit=limit,
principal_id=principal_id,
identity=identity,
cursor=cursor,
)

Expand Down
38 changes: 36 additions & 2 deletions tests/server/test_audit_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp

assert response.status_code == status.HTTP_200_OK
params = response.json()["paths"]["/api/audit/events"]["get"]["parameters"]
assert {param["name"] for param in params} == {"limit", "cursor"}
assert {param["name"] for param in params} == {"limit", "cursor", "identity"}


def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None:
Expand Down Expand Up @@ -270,11 +270,45 @@ def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity(
body = response.json()
assert body["scope"] == "principal"
event_ids = {entry["event_id"] for entry in body["entries"]}
assert "audit_011" in event_ids
assert "audit_010" not in event_ids
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"])


def test_audit_events_can_filter_current_principal_by_identity(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))

with create_server_test_client() as client:
_claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com")
_claim_identity(client, tmp_path, "calmly-simply-boldly-0043", email="user@example.com")
whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"),
).json()
_emit_audit_event(
"audit_020",
"connection.login",
principal_id=whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
)
_emit_audit_event(
"audit_021",
"connection.logout",
principal_id=whoami["principal_id"],
identity="calmly-simply-boldly-0043",
provider="linear",
)
path = "/api/audit/events?identity=calmly-simply-boldly-0043&limit=10"
response = client.get(
path,
headers=_auth_header(tmp_path, "GET", path, handle="steady-wisely-boldly-0042"),
)

assert response.status_code == status.HTTP_200_OK
manual_entries = [entry for entry in response.json()["entries"] if entry["event_id"].startswith("audit_02")]
assert [entry["event_id"] for entry in manual_entries] == ["audit_021"]


def test_admin_audit_events_support_cursor_pagination(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))

Expand Down
21 changes: 21 additions & 0 deletions tests/server/test_pop_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ def test_resolve_identity_by_did_returns_handle(monkeypatch, tmp_path: Path) ->
assert response.json()["did"] == identity.did


def test_identity_detail_returns_owner_status_and_active_flag(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")

with create_server_test_client() as client:
register_and_claim_identity(client, tmp_path, identity.handle)
response = client.get(
f"/api/identities/{identity.handle}/detail",
headers=_auth_header(tmp_path, "GET", f"/api/identities/{identity.handle}/detail"),
)

assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["handle"] == identity.handle
assert body["did"] == identity.did
assert body["claim_status"] == "accepted"
assert body["principal_id"].startswith("principal_")
assert body["is_active"] is True
assert body["created_at"]


def test_resolve_identity_by_did_returns_404_for_unknown_did(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
Expand Down
67 changes: 67 additions & 0 deletions ui/src/app/(authenticated)/agents/detail/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import useSWR from "swr";

import { AgentDetailBody } from "@/components/dashboard/agent-detail-view";
import { PageErrorState, PageLoadingState } from "@/components/dashboard/page-state";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ApiError, fetchAgentDetail, fetchAuditEvents } from "@/lib/authsome-api";

function AgentNotFoundCard() {
return (
<Card className="w-full max-w-md border-border/50 shadow-none">
<CardHeader>
<CardTitle>Agent not found</CardTitle>
<CardDescription>Open an agent from the agents list to view its details.</CardDescription>
</CardHeader>
<CardContent>
<Link className={buttonVariants({ variant: "outline" })} href="/agents">
Back to agents
</Link>
</CardContent>
</Card>
);
}

function AgentDetailContent() {
const searchParams = useSearchParams();
const agent = searchParams.get("agent") ?? "";
const detail = useSWR(agent ? ["authsome-agent-detail", agent] : null, () => fetchAgentDetail(agent));
const audit = useSWR(agent ? ["authsome-agent-audit", agent] : null, () => fetchAuditEvents({ identity: agent, limit: 25 }));

if (!agent) {
return <AgentNotFoundCard />;
}

if (detail.error instanceof ApiError && detail.error.status === 404) {
return <AgentNotFoundCard />;
}

if (detail.error || audit.error) {
return <PageErrorState title="Failed to load agent details" />;
}

if (!detail.data || !audit.data) {
return (
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
<PageLoadingState columns={4} />
</CardContent>
</Card>
);
}

return <AgentDetailBody agent={detail.data} events={audit.data.events} />;
}

export default function AgentDetailPage() {
return (
<Suspense fallback={null}>
<AgentDetailContent />
</Suspense>
);
}
7 changes: 7 additions & 0 deletions ui/src/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ function buildBreadcrumbs(
];
}

if (first === "agents") {
return [
{ label: parent, href: parentHref },
{ label: searchParams.get("agent") || "Detail" },
];
}

return [{ label: parent }];
}

Expand Down
Loading
Loading