diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py
index 19d770e8..750f41a2 100644
--- a/src/authsome/server/routes/audit.py
+++ b/src/authsome/server/routes/audit.py
@@ -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
@@ -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:
diff --git a/src/authsome/server/routes/identities.py b/src/authsome/server/routes/identities.py
index 5e167721..2ae19231 100644
--- a/src/authsome/server/routes/identities.py
+++ b/src/authsome/server/routes/identities.py
@@ -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"])
@@ -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)
diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py
index d550a037..e1c13755 100644
--- a/src/authsome/server/schemas.py
+++ b/src/authsome/server/schemas.py
@@ -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
diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py
index 6d06355d..cf58b206 100644
--- a/src/authsome/server/store/repositories.py
+++ b/src/authsome/server/store/repositories.py
@@ -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)
@@ -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)
@@ -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,
)
diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py
index 595d0f8c..ef537fcd 100644
--- a/tests/server/test_audit_events.py
+++ b/tests/server/test_audit_events.py
@@ -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:
@@ -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))
diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py
index 5c11bf28..3d021da3 100644
--- a/tests/server/test_pop_auth.py
+++ b/tests/server/test_pop_auth.py
@@ -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")
diff --git a/ui/src/app/(authenticated)/agents/detail/page.tsx b/ui/src/app/(authenticated)/agents/detail/page.tsx
new file mode 100644
index 00000000..eb332fd2
--- /dev/null
+++ b/ui/src/app/(authenticated)/agents/detail/page.tsx
@@ -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 (
+