Merge branch 'main' into 417-comparison-with-one-cli-secrets-manager

This commit is contained in:
rishabhraj36
2026-06-16 14:36:35 +05:30
36 changed files with 853 additions and 477 deletions
+3
View File
@@ -1,2 +1,5 @@
# PostHog public project API key — intentionally committed, not a secret
src/authsome/server/analytics.py:generic-api-key:50
# Logo.dev public project token — intentionally committed for provider logos
ui/src/components/dashboard/dashboard-primitives.tsx:generic-api-key:12
+3 -3
View File
@@ -73,9 +73,9 @@ Docs say default: `~/.authsome/logs/authsome.log`.
---
### 1e. `profile` command exists but is not documented
### 1e. Legacy `profile` command is removed
`authsome profile` with subcommands `create` and `use` appears in the CLI but is absent from `docs/site/reference/cli.mdx`.
The local signing-key command surface is `authsome agent create` and `authsome agent use`.
---
@@ -224,7 +224,7 @@ If the behavior differs for bundled vs. custom providers, the `--help` text shou
| P1 | Fix `--quiet` — stop suppressing data output | Medium |
| P1 | Fix `--force` on `register` — imply `--yes` or document split | Small |
| P1 | Add `connection set-default` subgroup (or alias to match docs) | Small |
| P1 | Document `profile` command, `shell` export format, corrected `--log-file` path | Small |
| P1 | Document `shell` export format and corrected `--log-file` path | Small |
| P2 | Resolve `inspect` vs `get` overlap — pick a clear model | Medium |
| P2 | Add human-readable default to `inspect` and `daemon status` | Medium |
| P2 | Fix `daemon stop` — wait for actual stop before returning | Medium |
+9 -9
View File
@@ -39,17 +39,17 @@ uv run authsome whoami
**Expected (first run):** the command prints a claim URL to stderr, opens it in a
browser, and blocks while polling:
```
Open this URL in your browser to claim this identity:
Open this URL in your browser to claim this agent:
http://127.0.0.1:7998/claim?token=claim_<token>
```
**Human action:**
1. The browser opens the claim page automatically (open the printed URL yourself if it doesn't, e.g. on a headless box).
2. Register with an email + password — or log in if the account already exists. The first account on a fresh server becomes the **admin** Principal.
3. Confirm that the displayed identity handle is yours.
3. Confirm that the displayed agent handle is yours.
4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step.
**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).
**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `agent` (registered non-default agent handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).
```bash
uv run authsome doctor
@@ -327,20 +327,20 @@ uv run authsome provider list # github connection gone
---
## 15. Profiles
## 15. Agents
```bash
uv run authsome profile create --handle work
uv run authsome agent create --handle work
```
**Expected:** `{"v": 1, "status": "created", "profile": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this profile triggers its own browser claim.
**Expected:** `{"v": 1, "status": "created", "agent": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this agent triggers its own browser claim.
```bash
uv run authsome profile use work
uv run authsome whoami # profile reflects "work" (claim required on first use)
uv run authsome agent use work
uv run authsome whoami # agent reflects "work" (claim required on first use)
```
**Expected:** `profile use` `{"status": "active", "profile": "work", ...}`.
**Expected:** `agent use` -> `{"status": "active", "agent": "work", ...}`.
---
+15
View File
@@ -49,6 +49,21 @@ authsome log # Print recent audit events
authsome log --json # Output JSON format
```
### User-scoped access
`GET /api/audit/events` is role-aware. Admin principals can review the global audit log.
Non-admin principals receive only events scoped to their own principal, including their
claimed identities, vault, providers, and credential lifecycle activity.
Supported query parameters:
| Parameter | Description |
| --- | --- |
| `limit` | Number of events to return, clamped to the server maximum. |
| `cursor` | Cursor returned by the previous page. |
Results are sorted newest-first and include `next_cursor` when another page is available.
## Privacy and Secrets
**What the log contains:**
+8 -8
View File
@@ -12,11 +12,11 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
| `connections` | Inspect and manage stored provider connections. |
| `daemon` | Manage the local Authsome daemon. |
| `doctor` | Run health checks on directory layout and encryption. |
| `init` | Initialize local storage and register a fresh profile. |
| `agent` | Manage local agents backed by signing keys. |
| `init` | Initialize local storage and register a fresh agent. |
| `log` | View structured audit entries or the raw client debug log. |
| `login <provider>` | Authenticate with PROVIDER using the configured flow. |
| `logout <provider>` | Log out of the specified PROVIDER connection. |
| `profile` | Manage local profiles backed by identity keys. |
| `provider` | Manage provider definitions and provider-level operations. |
| `run -- <cmd>` | Run COMMAND as a subprocess injected with authentication credentials. |
| `scan` | Scan env files and process env for provider API keys. |
@@ -38,8 +38,8 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
### `init` / `whoami` / `doctor`
```bash
authsome init # initialize local storage and register profile
authsome whoami # show identity context and encryption mode
authsome init # initialize local storage and register agent
authsome whoami # show agent context and encryption mode
authsome doctor # run health checks
authsome doctor --json # structured output for monitoring
```
@@ -153,14 +153,14 @@ Sets the default connection for a provider. The proxy and library calls use the
authsome connections set-default github work
```
### `profile`
### `agent`
```bash
authsome profile create # create a new local profile keypair
authsome profile use # switch the active local profile
authsome agent create # create a new local agent keypair
authsome agent use # switch the active local agent
```
Profiles are backed by Ed25519 identity keys at `~/.authsome/identities/`. Each profile has its own credential namespace in the vault.
Agents are backed by Ed25519 signing keys at `~/.authsome/identities/`. Credentials are scoped to the active vault, not to the agent key.
### `daemon`
@@ -1,310 +0,0 @@
# Stateless Production Deployments Design
## Summary
Prepare Authsome for stateless, horizontally scalable production deployments while preserving the local developer defaults. The server will select production infrastructure from environment variables: Postgres for the relational server Store when `AUTHSOME_DATABASE_URL` uses a Postgres scheme, and Redis for shared mutable server state plus encrypted vault KV when `AUTHSOME_REDIS_URL` is present.
The design keeps the existing module ownership model intact. `identity`, `auth`, and `vault` remain reusable libraries with infrastructure-agnostic contracts and domain behavior. `server` remains the composition root that chooses concrete infrastructure and combines the libraries into Authsome business logic.
## Goals
- Make container and multi-replica deployments viable without relying on local process memory or ephemeral disk for hot-path mutable state.
- Keep SQLite, disk vault storage, and in-memory transient state working for local development and tests.
- Reuse `py-key-value-aio` Redis support for vault storage instead of creating a custom Redis vault backend.
- Keep Postgres and Redis optional for library installs, while installing production extras in the Docker image.
- Provide self-hosting documentation with Postgres, Redis, Docker, and secret-management guidance.
## Non-Goals
- Do not introduce an ORM or Alembic for this refactor. Use a lightweight schema-version migration runner inside the existing Store adapter.
- Do not move business logic into CLI or proxy. They continue to communicate with the server.
- Do not add stateful browser sessions in this refactor. Browser sessions remain signed stateless JWT cookies.
- Do not add email verification or signup abuse prevention in this refactor. Those are tracked separately in GitHub issue #411.
- Do not introduce `AUTHSOME_VAULT_BACKEND`. Redis vault selection follows `AUTHSOME_REDIS_URL`.
## Current State
The current code already has a relational server Store split from vault storage:
- `src/authsome/server/store/database.py` supports SQLite and Postgres URL resolution, but Postgres uses a single `asyncpg` connection.
- `src/authsome/server/store/repositories.py` contains the five server-owned registries plus server config, custom provider definitions, and audit events.
- `src/authsome/server/dependencies.py` always creates the vault with `DiskStore`.
- `src/authsome/auth/sessions.py` stores auth flow sessions in process memory.
- `src/authsome/server/ui_sessions.py` keeps browser sessions stateless but stores pending identity-claim tokens in process memory.
- `src/authsome/identity/proof.py` validates PoP JWTs and currently owns an in-memory replay cache.
- `src/authsome/server/app.py` wires these components directly into `app.state`.
These defaults work for local development but do not work across multiple replicas. Auth flow sessions, pending claim tokens, and PoP replay JTIs need shared state. Vault encrypted blobs need a backend that survives container restarts without requiring a mounted local volume in production.
## Architecture
Authsome keeps the existing boundaries:
- `identity` owns identity and PoP token semantics. It creates PoP JWTs, verifies signatures, verifies request binding, and extracts proof claims. It remains infrastructure agnostic.
- `auth` owns flow/session models and abstract session-store behavior. Concrete Redis storage does not leak into auth flow code.
- `vault` owns encrypted KV semantics over an `AsyncKeyValue`. It does not define a Redis-specific vault API.
- `server` owns deployment topology. It selects SQLite or Postgres, DiskStore or RedisStore, memory or Redis state stores, and wires the selected implementations into services and routes.
- `cli` and `proxy` remain clients of the server business logic.
- `ui` and the relational Store remain server properties.
Backend selection is simple:
- `AUTHSOME_DATABASE_URL=postgresql://...` or `postgres://...` selects Postgres for the relational server Store.
- No Postgres URL selects SQLite.
- `AUTHSOME_REDIS_URL` selects Redis for auth flow sessions, pending claim tokens, PoP JTI replay cache, and raw vault KV.
- No Redis URL selects in-memory transient stores and disk vault KV.
If an explicit Postgres or Redis backend is configured and the driver is missing or the service is unreachable, startup fails. There is no runtime fallback from Redis/Postgres to memory/disk after startup.
Browser UI sessions stay stateless signed cookies for now. The disadvantages are known: server-side logout/revocation and active session visibility are not available. Verified signup and stateful browser-session management are deferred to issue #411.
## Components
### Server Configuration
`src/authsome/server/config.py` will add:
- `redis_url: str | None`
- Postgres pool settings, such as min and max pool size.
- TTL settings used by Redis-backed auth sessions, pending claim tokens, and replay cache where existing constants are currently hard-coded.
Configuration remains environment-driven through the existing `AUTHSOME_` prefix.
### Relational Store
`src/authsome/server/store/database.py` keeps the current lightweight adapter but upgrades production behavior:
- SQLite continues to use one `aiosqlite` connection.
- Postgres uses an `asyncpg` pool.
- Queries still use `?` placeholders at repository call sites, translated to Postgres positional parameters inside the adapter.
- Startup runs a lightweight schema-version migration runner.
The migration runner should:
- Maintain `store_schema_version`.
- Apply ordered migration functions or statements.
- Support SQLite and Postgres dialect fragments inside the Store module.
- Keep existing `CREATE TABLE IF NOT EXISTS` bootstrap behavior only as migration contents, not as ad hoc schema setup scattered through startup.
The existing registries remain repository classes. They should not learn about pools, Postgres clients, or migration internals.
### Replay Cache
The anti-replay cache prevents reuse of a PoP JWT within its validity window. Each PoP JWT has a `jti`. After signature, method, URL, body hash, and expiry validation, the server checks whether that `jti` has already been used. If it has, the request is rejected.
The split should be:
- `identity.proof` owns proof semantics and accepts an injected infrastructure-agnostic replay checker.
- A tiny protocol defines the operation shape: `check_and_store(jti: str, exp: int) -> None`.
- Server-side implementations provide storage:
- Memory implementation for local dev and tests.
- Redis implementation for production.
The Redis implementation should use an atomic set-if-not-exists operation with a TTL derived from `exp - now`. This lets replica B reject a JWT already accepted by replica A.
No Redis import belongs in `identity`.
### Auth Flow Sessions
`AuthSession` remains the domain model in `src/authsome/auth/sessions.py`.
The current in-memory `AuthSessionStore` behavior should be preserved behind a small store interface that covers the existing route needs:
- `create(...)`
- `get(session_id)`
- `save(session)`
- `delete(session_id)`
- `index_oauth_state(session)`
- `get_by_oauth_state(state)`
A Redis implementation can live server-side if it imports Redis-specific code. It should serialize `AuthSession` with Pydantic JSON, store each session under a namespaced key, and store OAuth state-to-session mappings under separate keys with matching TTLs.
Local memory behavior remains available when `AUTHSOME_REDIS_URL` is absent.
### Pending Claim Tokens
Browser sessions remain stateless in `UiSessionStore`, but pending claim tokens need shared mutable state so claim links survive replica changes.
The browser session methods stay simple:
- `create_browser_session(...)`
- `get_browser_session(cookie_value)`
- `build_cookie_value(token)`
- `delete_browser_session(cookie_value)`
Pending claim methods move behind a memory/Redis store:
- `create_pending_claim(identity, ttl_seconds)`
- `get_pending_claim(token)`
- `consume_pending_claim(token)`
`consume_pending_claim` should delete and return the token. The Redis version should be atomic where the Redis client makes that practical.
### Vault KV Backend
The vault continues to use `Vault -> AesGcmEncryptionWrapper -> AsyncKeyValue`.
`src/authsome/server/dependencies.py` chooses the raw `AsyncKeyValue`:
- No `AUTHSOME_REDIS_URL`: `DiskStore(directory=server_config.kv_store_dir)`
- `AUTHSOME_REDIS_URL`: `key_value.aio.stores.redis.RedisStore(url=server_config.redis_url)`
The existing `DekManager` continues to load or create the wrapped DEK record through the raw KV backend. Redis stores only encrypted vault values and DEK wrapping metadata. The vault master key is never stored in Redis.
### Secrets
Master-key resolution keeps the current behavior in `src/authsome/server/secrets.py`:
1. `AUTHSOME_MASTER_KEY`
2. `AUTHSOME_MASTER_KEY_FILE` or the default server key file
3. OS keyring
4. Generate a new base64 key, store it in keyring if possible, otherwise write the default key file
There is no special production-mode enforcement tied to Redis or Postgres. The self-hosting guide should recommend `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE` for containers and explain that generated file keys only survive when the filesystem is persistent.
### App Lifecycle
`src/authsome/server/app.py` remains the composition root:
1. Load `ServerConfig`.
2. Open and migrate the relational Store.
3. If Redis is configured, create or validate Redis-backed state dependencies.
4. Create raw vault KV, load/create DEK, wrap with encryption, and construct `Vault`.
5. Create auth sessions, UI sessions/pending claim store, replay cache, provider repository, account auth service, bootstrap service, and ownership resolver.
6. Close Store pools and Redis-owned clients on shutdown.
The existing `ownership_cache = {}` can remain a local optimization only if it is not correctness-critical. If it can become stale across replicas for claim/binding changes, it should be removed or given a conservative TTL. Correctness must come from the registries, not the process cache.
## Data Flow
### Startup
Local startup without production URLs uses SQLite, DiskStore, and memory state. Postgres is selected only by a Postgres `AUTHSOME_DATABASE_URL`; Redis is selected only by `AUTHSOME_REDIS_URL`.
If Redis is configured, startup should ping Redis before serving requests. If Postgres is configured, startup should acquire a connection from the pool and run migrations before serving requests.
### PoP Requests
1. The request arrives with `Authorization: PoP <jwt>`.
2. `identity.proof.validate_proof_jwt()` validates the signature and request binding.
3. The injected replay checker stores the `jti` until expiry or raises if already seen.
4. The server resolves the identity registration and ownership through the relational Store.
5. The route receives the existing `ResolvedOwnership` and builds `CredentialService`.
### Auth Flow Sessions
1. A login flow creates an `AuthSession`.
2. The selected session store persists it with a TTL.
3. OAuth flows index `internal_state` to the session id.
4. Callback routes resolve the session by OAuth state or session id, update the session, and save it.
5. Expired or missing sessions behave as not found.
### Pending Claim Links
1. Identity bootstrap creates a pending claim token.
2. The selected pending claim store persists it with a TTL.
3. The claim route consumes the token.
4. Consumed or expired tokens behave as not found.
### Vault Access
1. `CredentialRepository` reads or writes credentials through `Vault`.
2. `Vault` updates its index records and plaintext domain values.
3. `AesGcmEncryptionWrapper` encrypts the values.
4. DiskStore or RedisStore stores encrypted blobs using the existing collection/key naming scheme, including `vault:<vault_id>:...` collections.
## Error Handling
Startup failures:
- Invalid database URL scheme fails clearly.
- Postgres driver missing, connection failure, bad credentials, or migration failure fails startup.
- Redis driver missing, connection failure, bad credentials, or ping failure fails startup.
- Vault DEK unwrap failure fails startup.
Runtime behavior:
- Redis outages during affected operations return 5xx responses. The server does not silently fall back to memory or disk.
- PoP replay detection returns the existing unauthorized proof-validation response.
- Expired sessions and pending claim tokens behave as not found.
- Health remains cheap and public. Keep `/api/health` and add a root `/health` alias for container health checks.
- Readiness checks the relational Store and vault. If Redis is configured, readiness also checks Redis connectivity.
## Docker And Self-Hosting
The Docker image should install production extras by default while the base Python package keeps them optional where possible.
The Dockerfile should:
- Keep a multi-stage build for UI and Python package.
- Use the `uv` toolchain for Python build/install.
- Run as a non-root user.
- Expose port 7998.
- Add a root `/health` alias backed by the same response as `/api/health`.
- Include a healthcheck against `/health`.
`docker-compose.yml` should include:
- `authsome`
- `postgres`
- `redis`
The self-hosting guide should cover:
- Prerequisites: Docker, Postgres, Redis.
- Environment variables: `AUTHSOME_DATABASE_URL`, `AUTHSOME_REDIS_URL`, `AUTHSOME_MASTER_KEY`, `AUTHSOME_MASTER_KEY_FILE`, `AUTHSOME_HOME`, `AUTHSOME_BASE_URL`, `AUTHSOME_HOST`, `AUTHSOME_PORT`, and analytics settings.
- Startup steps: pull or build image, set env vars, start service, run `authsome init`, verify `/health`.
- Compose example for local production simulation.
- Secret guidance: do not commit production `AUTHSOME_MASTER_KEY`; prefer a cloud secret manager, Doppler, Vault, or platform secrets.
- Migration guidance: relational schema migrations run at startup; back up Postgres and Redis according to operator policy.
## Testing
Default `uv run pytest` should continue to pass without external services.
Tests to add or adjust:
- SQLite migration tests.
- Postgres migration tests gated behind an optional service fixture or environment variable.
- Postgres pool adapter tests gated behind the same integration mechanism.
- Memory replay cache tests after moving it out of `identity`.
- Redis replay cache tests for duplicate rejection and TTL behavior.
- Auth session store contract tests run against memory and Redis implementations.
- Pending claim store contract tests run against memory and Redis implementations.
- Vault backend tests showing RedisStore is selected when `AUTHSOME_REDIS_URL` is present and values remain encrypted.
- Server lifecycle tests for local defaults and Redis/Postgres selection failures.
- Existing session recreation tests should split local and Redis behavior: memory sessions do not survive app recreation; Redis sessions do.
- Docker smoke test for image build and `/health`.
Verification before completion should include:
- `uv run pytest`
- `uv run ruff check`
- `uv run ty check`
- Docker build smoke test when Docker is available
- Redis/Postgres integration tests when services are available
## Rollout Plan
Implement in small phases inside one production-readiness branch:
1. Add config fields and optional dependency extras.
2. Upgrade the relational Store to Postgres pooling and lightweight migrations.
3. Split replay-cache semantics cleanly from `identity` and add memory/Redis implementations.
4. Introduce auth session store contracts and Redis-backed auth sessions.
5. Split pending claim storage from stateless browser session signing and add Redis pending claims.
6. Reuse `py-key-value-aio[redis]` for vault raw KV when `AUTHSOME_REDIS_URL` is configured.
7. Update app lifecycle, readiness, Dockerfile, compose, and self-hosting docs.
8. Add gated integration tests and smoke verification.
Each phase should preserve local defaults and keep implementation changes close to the modules that own the behavior.
## Open Follow-Up
GitHub issue #411 tracks hosted login hardening outside this refactor:
- Email verification during signup.
- Signup abuse prevention.
- Stateful browser sessions.
- Server-side browser-session logout and revocation.
- Session visibility and account-security policies.
+6 -6
View File
@@ -53,7 +53,7 @@ def raise_for_error(response: httpx.Response) -> None:
try:
data = response.json()
if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle":
raise err_mod.IdentityNotRegisteredError("current identity") from exc
raise err_mod.IdentityNotRegisteredError("current agent") from exc
error_name = data.get("error")
message = data.get("message")
if error_name and message:
@@ -175,15 +175,15 @@ class AuthsomeApiClient:
self._open_claim_url(claim_url)
await self._poll_claim_completion(runtime.handle)
elif reg_status == "rejected":
raise RuntimeError(f"Identity '{runtime.handle}' claim was rejected by the server")
raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server")
def _open_claim_url(self, claim_url: str) -> None:
print(f"Open this URL in your browser to claim this identity:\n {claim_url}", file=sys.stderr)
print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr)
with suppress(Exception):
webbrowser.open(claim_url)
async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> None:
print("Waiting for identity to be claimed...", file=sys.stderr)
print("Waiting for agent to be claimed...", file=sys.stderr)
deadline = asyncio.get_running_loop().time() + timeout_seconds
while True:
status = await self.get_identity_status(handle)
@@ -191,9 +191,9 @@ class AuthsomeApiClient:
if reg_status == "claimed":
return
if reg_status == "rejected":
raise RuntimeError(f"Identity '{handle}' claim was rejected")
raise RuntimeError(f"Agent '{handle}' claim was rejected")
if asyncio.get_running_loop().time() >= deadline:
raise TimeoutError(f"Timed out waiting for identity '{handle}' to be claimed")
raise TimeoutError(f"Timed out waiting for agent '{handle}' to be claimed")
await asyncio.sleep(1)
async def _get(self, path: str, *, protected: bool = True) -> dict[str, Any]:
+2 -2
View File
@@ -1,9 +1,9 @@
"""CLI command registration."""
import authsome.cli.commands.agent as agent_module
import authsome.cli.commands.connections as connections_module
import authsome.cli.commands.core as core_module
import authsome.cli.commands.daemon as daemon_module
import authsome.cli.commands.profile as profile_module
import authsome.cli.commands.provider as provider_module
@@ -19,5 +19,5 @@ def register_commands(cli) -> None:
cli.add_command(core_module.log_cmd)
cli.add_command(provider_module.provider)
cli.add_command(connections_module.connections)
cli.add_command(profile_module.profile)
cli.add_command(agent_module.agent)
cli.add_command(daemon_module.daemon)
@@ -1,4 +1,4 @@
"""Profile CLI commands."""
"""Local agent CLI commands."""
import click
@@ -9,23 +9,19 @@ from authsome.cli.identity import RuntimeIdentity
from authsome.config import get_authsome_config
@click.group(name="profile")
def profile() -> None:
"""Manage local profiles backed by identity keys."""
@click.group(name="agent")
def agent() -> None:
"""Manage local agents backed by signing keys."""
@profile.command(name="create")
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local profile handle.")
@auth_command
async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
"""Create a local profile keypair."""
async def _create_agent(ctx_obj: ContextObj, handle: str | None) -> None:
home = get_authsome_config().home
identity = RuntimeIdentity.create(home, handle)
data = {
"status": "created",
"home": str(home),
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
"registration_status": "local",
"switched": True,
@@ -33,18 +29,30 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
ctx_obj.print_json(data)
@profile.command(name="use")
@click.argument("handle")
@auth_command
async def profile_use(ctx_obj: ContextObj, handle: str) -> None:
"""Select the active local profile."""
async def _use_agent(ctx_obj: ContextObj, handle: str) -> None:
home = get_authsome_config().home
identity = RuntimeIdentity.from_filesystem(home, handle)
ClientConfig.load(home).model_copy(update={"active_identity": identity.handle}).save(home)
data = {
"status": "active",
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
}
ctx_obj.print_json(data)
@agent.command(name="create")
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local agent handle.")
@auth_command
async def agent_create(ctx_obj: ContextObj, handle: str | None) -> None:
"""Create a local agent keypair."""
await _create_agent(ctx_obj, handle)
@agent.command(name="use")
@click.argument("handle")
@auth_command
async def agent_use(ctx_obj: ContextObj, handle: str) -> None:
"""Select the active local agent."""
await _use_agent(ctx_obj, handle)
+4 -3
View File
@@ -269,7 +269,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None:
@click.command()
@auth_command
async def init(ctx_obj: ContextObj) -> None:
"""Initialize local storage and register a fresh profile."""
"""Initialize local storage and register a fresh agent."""
home = get_authsome_config().home
RuntimeIdentity.ensure_local(home)
@@ -280,7 +280,7 @@ async def init(ctx_obj: ContextObj) -> None:
data = {
"status": "initialized",
"home": str(home),
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
"registration_status": "registered",
"configured_encryption_mode": whoami_data.get("configured_encryption_mode"),
@@ -319,10 +319,11 @@ async def whoami(ctx_obj: ContextObj) -> None:
issues.append(f"connections: {exc}")
vault_status = "ERROR"
agent = whoami_data.get("identity", whoami_data.get("active_identity"))
data = {
"authsome_version": whoami_data["version"],
"home_directory": whoami_data["home"],
"profile": whoami_data.get("identity", whoami_data.get("active_identity")),
"agent": agent,
"principal_id": whoami_data.get("principal_id"),
"vault_id": whoami_data.get("vault_id"),
"did": whoami_data.get("did"),
+23 -4
View File
@@ -64,12 +64,31 @@ class AccountAuthService:
principal = await self._principals.get_by_email(self._normalize_email(email))
if principal is None or not principal.password_hash:
raise ValueError("Invalid email or password")
try:
self._hasher.verify(principal.password_hash, password)
except (VerificationError, VerifyMismatchError) as exc:
raise ValueError("Invalid email or password") from exc
self._verify_password(principal.password_hash, password, message="Invalid email or password")
return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email)
async def change_password(
self,
*,
principal_id: str,
current_password: str,
new_password: str,
) -> PrincipalRecord:
principal = await self._principals.get(principal_id)
if principal is None or not principal.password_hash:
raise ValueError("Invalid current password")
self._verify_password(principal.password_hash, current_password, message="Invalid current password")
self._validate_password(new_password)
return await self._principals.update_password(principal_id, password_hash=self._hasher.hash(new_password))
def _verify_password(self, password_hash: str, password: str, *, message: str) -> None:
try:
self._hasher.verify(password_hash, password)
except (VerificationError, VerifyMismatchError) as exc:
raise ValueError(message) from exc
except ValueError as exc:
raise ValueError(message) from exc
@staticmethod
def _normalize_email(email: str) -> str:
normalized = email.strip().lower()
+2 -2
View File
@@ -40,7 +40,7 @@ from authsome.server.ui_sessions import UiSessionStore
async def _cleanup_startup_resources(store, audit_log, runtime_state) -> None:
with suppress(Exception):
if audit_log is not None:
audit_log.shutdown()
await audit_log.async_shutdown()
with suppress(Exception):
if store is not None:
await store.close()
@@ -99,7 +99,7 @@ async def lifespan(app: FastAPI):
try:
shutdown_posthog()
if audit_log is not None:
audit_log.shutdown()
await audit_log.async_shutdown()
if store is not None:
await store.close()
finally:
+14 -4
View File
@@ -1,8 +1,8 @@
"""Audit event routes."""
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from authsome import audit
from authsome.identity.principal import PrincipalRole
@@ -19,10 +19,20 @@ router = APIRouter(prefix="/audit", tags=["audit"])
async def list_audit_events(
request: Request,
limit: int = 50,
cursor: str | None = None,
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
) -> dict[str, Any]:
principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
return {"entries": await request.app.state.audit_log.list_events(limit=limit, principal_id=principal_id)}
effective_principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id
scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal"
try:
page = await request.app.state.audit_log.query_events(
limit=limit,
principal_id=effective_principal_id,
cursor=cursor,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return {"entries": page.entries, "next_cursor": page.next_cursor, "scope": scope}
@router.post("/events")
+34
View File
@@ -102,6 +102,11 @@ def _account_auth_next_url(value: Any) -> str:
return next_url
def _append_query(url: str, values: dict[str, str]) -> str:
separator = "&" if "?" in url else "?"
return f"{url}{separator}{urlencode(values)}"
@router.post("/auth/providers/{provider_name}/connect", include_in_schema=False)
async def connect_provider( # noqa: PLR0913
provider_name: str,
@@ -243,6 +248,35 @@ async def register_account(
return response
@router.post("/auth/password", include_in_schema=False)
async def change_account_password(request: Request) -> Response:
await resolve_ui_request_identity(request)
principal_id = getattr(request.state, "ui_principal_id", None)
form = await request.form()
next_url = _account_auth_next_url(form.get("next") or "/settings?tab=security")
if not principal_id:
return RedirectResponse(url=_account_auth_entry_url(next_url), status_code=status.HTTP_303_SEE_OTHER)
try:
await request.app.state.account_auth_service.change_password(
principal_id=principal_id,
current_password=str(form.get("current_password", "")),
new_password=str(form.get("new_password", "")),
)
except ValueError as exc:
return RedirectResponse(
url=_append_query(next_url, {"password_error": str(exc)}),
status_code=status.HTTP_303_SEE_OTHER,
)
audit.emit_event("account.password_changed", principal_id=principal_id, status="success")
capture_event(getattr(request.state, "ui_email", ""), "account_password_changed", {"principal_id": principal_id})
return RedirectResponse(
url=_append_query(next_url, {"password_changed": "1"}),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post("/auth/login", include_in_schema=False)
async def login_account(
request: Request,
+99 -16
View File
@@ -1,6 +1,8 @@
"""Typed repositories for server-owned relational Store records."""
import asyncio
import base64
import binascii
import builtins
import json
import threading
@@ -65,6 +67,42 @@ class AuditEventInsert:
payload: dict[str, Any]
@dataclass(frozen=True)
class AuditEventPage:
"""Paged audit event query result."""
entries: list[dict[str, Any]]
next_cursor: str | None
def _encode_audit_cursor(*, timestamp: str, event_id: str) -> str:
payload = json.dumps(
{"event_id": event_id, "timestamp": timestamp},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def _decode_audit_cursor(cursor: str) -> tuple[str, str]:
valid_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
if not cursor or any(char not in valid_chars for char in cursor):
raise ValueError("Invalid audit cursor")
try:
padded_cursor = cursor + "=" * (-len(cursor) % 4)
decoded = base64.urlsafe_b64decode(padded_cursor.encode("ascii")).decode("utf-8")
payload = json.loads(decoded)
if not isinstance(payload, dict):
raise ValueError
timestamp = payload.get("timestamp")
event_id = payload.get("event_id")
if not isinstance(timestamp, str) or not timestamp or not isinstance(event_id, str) or not event_id:
raise ValueError
except (binascii.Error, json.JSONDecodeError, UnicodeError, ValueError, TypeError):
raise ValueError("Invalid audit cursor") from None
return timestamp, event_id
class AuditEventRegistry:
"""Relational audit event registry."""
@@ -96,21 +134,44 @@ class AuditEventRegistry:
],
)
async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]:
async def query_events(
self,
*,
limit: int = 50,
principal_id: str | None = None,
cursor: str | None = None,
) -> AuditEventPage:
bounded_limit = min(max(limit, 1), 500)
if principal_id is None:
rows = await self._db.fetch_all(
"SELECT payload_json FROM audit_events ORDER BY timestamp DESC, event_id DESC LIMIT ?",
[bounded_limit],
)
else:
rows = await self._db.fetch_all(
"SELECT payload_json FROM audit_events "
"WHERE principal_id = ? "
"ORDER BY timestamp DESC, event_id DESC LIMIT ?",
[principal_id, bounded_limit],
)
return [json.loads(row["payload_json"]) for row in rows]
conditions: list[str] = []
params: list[Any] = []
if principal_id is not None:
conditions.append("principal_id = ?")
params.append(principal_id)
if cursor:
cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor)
conditions.append("(timestamp < ? OR (timestamp = ? AND event_id < ?))")
params.extend([cursor_timestamp, cursor_timestamp, cursor_event_id])
where_clause = f" WHERE {' AND '.join(conditions)}" if conditions else ""
rows = await self._db.fetch_all(
"SELECT event_id, timestamp, payload_json FROM audit_events"
f"{where_clause} "
"ORDER BY timestamp DESC, event_id DESC LIMIT ?",
[*params, bounded_limit + 1],
)
visible_rows = rows[:bounded_limit]
entries = [json.loads(row["payload_json"]) for row in visible_rows]
next_cursor = None
if len(rows) > bounded_limit and visible_rows:
last = visible_rows[-1]
next_cursor = _encode_audit_cursor(timestamp=last["timestamp"], event_id=last["event_id"])
return AuditEventPage(entries=entries, next_cursor=next_cursor)
async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]:
page = await self.query_events(limit=limit, principal_id=principal_id)
return page.entries
def configure_exporter(self, loop: asyncio.AbstractEventLoop | None = None):
"""Configure the process OTel logger provider to export audit logs to Store."""
@@ -155,7 +216,6 @@ class _StoreAuditExporter(LogRecordExporter):
future = asyncio.run_coroutine_threadsafe(self._registry.insert_many(rows), self._loop)
with self._lock:
self._futures.append(future)
future.result()
except Exception as exc:
logger.warning("Could not persist audit events: {}", exc)
return LogRecordExportResult.FAILURE
@@ -191,6 +251,10 @@ class _StoreAuditExporter(LogRecordExporter):
self._closed = True
self.force_flush()
def close(self) -> None:
self._closed = True
self._drop_finished_futures()
def _is_loop_thread(self) -> bool:
try:
return asyncio.get_running_loop() is self._loop
@@ -257,9 +321,28 @@ class ServerAuditLog:
self._exporter.force_flush()
async def async_force_flush(self) -> None:
self._provider.force_flush()
await asyncio.to_thread(self._provider.force_flush)
await self._exporter.async_force_flush()
async def async_shutdown(self) -> None:
await self.async_force_flush()
_delegating_audit_exporter.set_active(None)
self._exporter.close()
async def query_events(
self,
*,
limit: int = 50,
principal_id: 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,
cursor=cursor,
)
async def list_events(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]:
await self.async_force_flush()
return await self._registry.list_recent(limit=limit, principal_id=principal_id)
+1 -1
View File
@@ -147,7 +147,7 @@ class TestAuthServiceRefreshLogs:
try:
yield log
finally:
log.shutdown()
await log.async_shutdown()
await store.close()
@pytest.fixture
+2 -2
View File
@@ -244,7 +244,7 @@ async def test_unregistered_identity_registers_on_first_use(monkeypatch, tmp_pat
@pytest.mark.asyncio
async def test_bootstrapped_identity_is_saved_as_active_profile(monkeypatch, tmp_path: Path) -> None:
async def test_bootstrapped_identity_is_saved_as_active_agent(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
captured: dict = {}
@@ -322,7 +322,7 @@ async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_p
@pytest.mark.asyncio
async def test_env_identity_does_not_update_active_profile(monkeypatch, tmp_path: Path) -> None:
async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
stored = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
ClientConfig(active_identity=stored.handle).save(tmp_path)
+24 -25
View File
@@ -1,4 +1,4 @@
"""Tests for `authsome profile` commands."""
"""Tests for `authsome agent` commands."""
import json
from pathlib import Path
@@ -8,48 +8,47 @@ from authsome.cli.identity import RuntimeIdentity
from authsome.cli.main import cli
class TestProfileCommands:
"""Tests for local profile management commands."""
class TestAgentCommands:
"""Tests for local agent management commands."""
def test_profile_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None:
def test_root_help_shows_agent_not_legacy_profile(self, runner) -> None:
result = runner.invoke(cli, ["--log-file", "", "--help"])
assert result.exit_code == 0, result.output
assert "agent" in result.output
assert "profile" not in result.output
def test_profile_command_is_removed(self, runner) -> None:
result = runner.invoke(cli, ["--log-file", "", "profile", "--help"])
assert result.exit_code != 0
assert "No such command 'profile'" in result.output
def test_agent_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None:
result = runner.invoke(
cli,
["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"],
["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["status"] == "created"
assert data["profile"] == "steady-wisely-boldly-0042"
assert data["agent"] == "steady-wisely-boldly-0042"
assert data["switched"] is True
stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042")
assert stored.did == data["did"]
assert ClientConfig.load(tmp_path).active_identity == stored.handle
def test_profile_create_switches_active_profile(self, runner, mock_client, tmp_path: Path) -> None:
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"])
result = runner.invoke(
cli,
["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"],
)
data = json.loads(result.output)
assert result.exit_code == 0, result.output
assert data["status"] == "created"
assert data["profile"] == "rapid-brightly-firmly-0007"
assert data["switched"] is True
assert ClientConfig.load(tmp_path).active_identity == "rapid-brightly-firmly-0007"
def test_profile_use_sets_active_identity(self, runner, mock_client, tmp_path: Path) -> None:
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"])
runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"])
def test_agent_use_sets_active_agent(self, runner, mock_client, tmp_path: Path) -> None:
runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"])
runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "rapid-brightly-firmly-0007"])
stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042")
result = runner.invoke(cli, ["--log-file", "", "profile", "use", "steady-wisely-boldly-0042"])
result = runner.invoke(cli, ["--log-file", "", "agent", "use", "steady-wisely-boldly-0042"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["status"] == "active"
assert data["profile"] == stored.handle
assert data["agent"] == stored.handle
assert data["did"] == stored.did
assert ClientConfig.load(tmp_path).active_identity == stored.handle
+6 -4
View File
@@ -26,7 +26,8 @@ def test_init_removes_legacy_default_state_and_registers_identity(
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["profile"] != "default"
assert data["agent"] != "default"
assert "profile" not in data
assert data["registration_status"] == "registered"
assert data["configured_encryption_mode"] == "auto"
assert data["effective_encryption_source"] == "local_key"
@@ -38,10 +39,10 @@ def test_init_removes_legacy_default_state_and_registers_identity(
config_data = ClientConfig.load(tmp_path)
assert config_data.version == __version__
assert config_data.active_identity == data["profile"]
assert config_data.active_identity == data["agent"]
def test_init_skips_registration_for_registered_active_profile(
def test_init_skips_registration_for_registered_active_agent(
runner,
mock_client,
tmp_path: Path,
@@ -53,6 +54,7 @@ def test_init_skips_registration_for_registered_active_profile(
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["profile"] == identity.handle
assert data["agent"] == identity.handle
assert "profile" not in data
assert data["configured_encryption_mode"] == "auto"
mock_client.ensure_identity_ready.assert_called_once()
+4 -2
View File
@@ -40,7 +40,8 @@ class TestWhoamiCommand:
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["authsome_version"] == "1.2.3"
assert data["profile"] == "steady-wisely-boldly-0042"
assert data["agent"] == "steady-wisely-boldly-0042"
assert "profile" not in data
assert data["principal_id"] == "principal_1"
assert data["vault_id"] == "vault_default"
assert data["vault_status"] == "OK"
@@ -90,7 +91,8 @@ class TestWhoamiCommand:
assert result.exit_code == 0
data = json.loads(result.output)
assert data["profile"] == "steady-wisely-boldly-0042"
assert data["agent"] == "steady-wisely-boldly-0042"
assert "profile" not in data
assert data["vault_status"] == "ERROR"
assert data["connected_providers_count"] == 0
assert any("connections:" in issue for issue in data["issues"])
+6
View File
@@ -12,6 +12,9 @@ os.environ["AUTHSOME_HOME"] = _tmp_dir.name
os.environ["AUTHSOME_BASE_URL"] = TEST_AUTHSOME_BASE_URL
os.environ["AUTHSOME_ENV"] = "test"
os.environ["AUTHSOME_DO_NOT_TRACK"] = "true"
os.environ.pop("AUTHSOME_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
os.environ.pop("AUTHSOME_REDIS_URL", None)
os.environ.pop("AUTHSOME_POSTHOG_API_KEY", None)
os.environ.pop("POSTHOG_API_KEY", None)
@@ -26,6 +29,9 @@ def _disable_analytics(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AUTHSOME_BASE_URL", TEST_AUTHSOME_BASE_URL)
monkeypatch.setenv("AUTHSOME_ENV", "test")
monkeypatch.setenv("AUTHSOME_DO_NOT_TRACK", "true")
monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False)
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False)
monkeypatch.delenv("AUTHSOME_POSTHOG_API_KEY", raising=False)
monkeypatch.delenv("POSTHOG_API_KEY", raising=False)
analytics.shutdown_posthog()
+29
View File
@@ -98,3 +98,32 @@ async def test_login_rejects_wrong_password(tmp_path: Path) -> None:
await service.login(email="dev@example.com", password="wrong-password")
finally:
await _close(store)
@pytest.mark.asyncio
async def test_change_password_requires_current_password_and_updates_login(tmp_path: Path) -> None:
service, store = await _service(tmp_path)
try:
principal = await service.register(email="dev@example.com", password="password-1")
with pytest.raises(ValueError, match="Invalid current password"):
await service.change_password(
principal_id=principal.principal_id,
current_password="wrong-password",
new_password="password-2",
)
await service.change_password(
principal_id=principal.principal_id,
current_password="password-1",
new_password="password-2",
)
with pytest.raises(ValueError, match="Invalid email or password"):
await service.login(email="dev@example.com", password="password-1")
session = await service.login(email="dev@example.com", password="password-2")
assert session.principal_id == principal.principal_id
finally:
await _close(store)
+245 -1
View File
@@ -1,12 +1,16 @@
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from authsome.audit import emit_event
from authsome.audit import AuditEvent, emit, emit_event
from authsome.cli.identity import RuntimeIdentity
from authsome.server.store import create_server_store
from tests.server.helpers import create_server_test_client
from tests.server.test_pop_auth import _auth_header
@@ -27,6 +31,31 @@ def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: s
assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == status.HTTP_303_SEE_OTHER
def _emit_audit_event( # noqa: PLR0913
event_id: str,
event: str,
*,
principal_id: str | None,
identity: str | None,
provider: str | None = None,
connection: str | None = None,
status: str | None = "success",
timestamp: datetime | None = None,
) -> None:
emit(
AuditEvent(
event_id=event_id,
timestamp=timestamp or datetime(2099, 1, 1, 8, 0, tzinfo=UTC),
event=event,
principal_id=principal_id,
identity=identity,
provider=provider,
connection=connection,
status=status,
)
)
def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
@@ -53,6 +82,17 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm
assert entries[0]["provider"] == "github"
def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
response = client.get("/openapi.json")
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"}
def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}}
@@ -125,3 +165,207 @@ def test_admin_sees_all_audit_events_and_user_sees_only_own_principal(monkeypatc
assert "user_event" in {entry["event"] for entry in user_entries}
assert "admin_event" not in {entry["event"] for entry in user_entries}
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in user_entries)
def test_non_admin_audit_query_params_do_not_filter_or_widen_scope(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
_claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com")
_claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com")
admin_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"),
).json()
user_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"),
).json()
_emit_audit_event(
"audit_001",
"connection.login",
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="github",
)
_emit_audit_event(
"audit_002",
"connection.login",
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
)
_emit_audit_event(
"audit_003",
"connection.logout",
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="linear",
)
response = client.get(
"/api/audit/events?provider=github&limit=10",
headers=_auth_header(
tmp_path,
"GET",
"/api/audit/events?provider=github&limit=10",
handle="steady-wisely-boldly-0042",
),
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert body["scope"] == "principal"
manual_entries = [entry for entry in body["entries"] if entry["event_id"].startswith("audit_00")]
assert [entry["event_id"] for entry in manual_entries] == ["audit_003", "audit_002"]
assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"])
def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_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, "admin-ready-boldly-0001", email="admin@example.com")
_claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com")
admin_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"),
).json()
user_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"),
).json()
_emit_audit_event(
"audit_010",
"connection.login",
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="github",
)
_emit_audit_event(
"audit_011",
"connection.login",
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
)
path = (
f"/api/audit/events?principal_id={admin_whoami['principal_id']}&identity=admin-ready-boldly-0001&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
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_admin_audit_events_support_cursor_pagination(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
_claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com")
_claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com")
admin_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"),
).json()
user_whoami = client.get(
"/api/whoami",
headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"),
).json()
_emit_audit_event(
"audit_100",
"connection.login",
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="github",
timestamp=datetime(2099, 1, 1, 8, 0, tzinfo=UTC),
)
_emit_audit_event(
"audit_099",
"connection.logout",
principal_id=admin_whoami["principal_id"],
identity="admin-ready-boldly-0001",
provider="linear",
timestamp=datetime(2099, 1, 1, 7, 59, tzinfo=UTC),
)
_emit_audit_event(
"audit_101",
"connection.login",
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
timestamp=datetime(2099, 1, 1, 8, 1, tzinfo=UTC),
)
_emit_audit_event(
"audit_102",
"connection.logout",
principal_id=user_whoami["principal_id"],
identity="steady-wisely-boldly-0042",
provider="github",
timestamp=datetime(2099, 1, 1, 8, 2, tzinfo=UTC),
)
first_path = "/api/audit/events?limit=2"
first_response = client.get(
first_path,
headers=_auth_header(
tmp_path,
"GET",
first_path,
handle="admin-ready-boldly-0001",
),
)
assert first_response.status_code == status.HTTP_200_OK
first_body = first_response.json()
second_path = f"/api/audit/events?limit=2&cursor={first_body['next_cursor']}"
second_response = client.get(
second_path,
headers=_auth_header(
tmp_path,
"GET",
second_path,
handle="admin-ready-boldly-0001",
),
)
assert first_body["scope"] == "global"
assert [entry["event_id"] for entry in first_body["entries"]] == ["audit_102", "audit_101"]
assert first_body["next_cursor"]
assert second_response.status_code == status.HTTP_200_OK
second_body = second_response.json()
assert second_body["scope"] == "global"
assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100", "audit_099"]
@pytest.mark.asyncio
async def test_audit_log_async_shutdown_flushes_events_without_blocking_loop(tmp_path: Path) -> None:
store = await create_server_store(home=tmp_path)
audit_log = store.audit_events.configure_exporter()
try:
emit_event("shutdown.flush", identity="agent-a", principal_id="principal_a", provider="github")
await asyncio.wait_for(audit_log.async_shutdown(), timeout=1)
entries = await store.audit_events.list_recent(limit=10, principal_id="principal_a")
finally:
await store.close()
assert [entry["event"] for entry in entries] == ["shutdown.flush"]
-9
View File
@@ -15,12 +15,3 @@ def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None:
assert root.status_code == status.HTTP_200_OK
assert root.json()["status"] == "ok"
assert root.json()["version"] == api.json()["version"]
def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
api_health_routes = [route for route in client.app.router.routes if getattr(route, "path", "") == "/api/health"]
assert len(api_health_routes) == 1
@@ -50,6 +50,9 @@ class FakeAuditLog:
def shutdown(self) -> None:
self.shutdown_called = True
async def async_shutdown(self) -> None:
self.shutdown()
class FakeStore:
def __init__(self, home: Path, audit_log: FakeAuditLog) -> None:
+40
View File
@@ -0,0 +1,40 @@
from fastapi import status
from tests.server.helpers import create_server_test_client
def test_browser_session_can_change_account_password(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
with create_server_test_client() as client:
registered = client.post(
"/api/auth/register",
data={"email": "dev@example.com", "password": "password-1", "next": "/settings?tab=security"},
follow_redirects=False,
)
response = client.post(
"/api/auth/password",
data={
"current_password": "password-1",
"new_password": "password-2",
"next": "/settings?tab=security",
},
follow_redirects=False,
)
client.post("/api/logout", follow_redirects=False)
old_login = client.post(
"/api/auth/login",
data={"email": "dev@example.com", "password": "password-1", "next": "/"},
follow_redirects=False,
)
new_login = client.post(
"/api/auth/login",
data={"email": "dev@example.com", "password": "password-2", "next": "/"},
follow_redirects=False,
)
assert registered.status_code == status.HTTP_303_SEE_OTHER
assert response.status_code == status.HTTP_303_SEE_OTHER
assert response.headers["location"] == "/settings?tab=security&password_changed=1"
assert old_login.headers["location"] == "/login?next=%2F&error=Invalid+email+or+password&tab=login"
assert new_login.status_code == status.HTTP_303_SEE_OTHER
+1 -1
View File
@@ -7,6 +7,6 @@ import { fetchDashboard } from "@/lib/authsome-api";
export default function AuditPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data || !data.account.isAdmin) return null;
if (!data) return null;
return <AuditView data={data} />;
}
+1 -1
View File
@@ -17,7 +17,7 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = {
title: "Authsome Dashboard",
description: "Local dashboard for Authsome identities, providers, and connections.",
description: "Local dashboard for Authsome agents, providers, and connections.",
};
export default function RootLayout({
+1 -1
View File
@@ -81,7 +81,7 @@ function ActiveView({
}
if (view === "agents") return <AgentsView data={data} />;
if (view === "principals") return <PrincipalsView />;
if (view === "audit" && data.account.isAdmin) return <AuditView data={data} />;
if (view === "audit") return <AuditView data={data} />;
if (view === "settings") return <SettingsView data={data} />;
return <DashboardView data={data} />;
}
+2 -2
View File
@@ -134,7 +134,7 @@ export function AuthsomeClaim({ token }: { token: string }) {
if (!data) {
return (
<AuthFlowShell
description="Checking this identity claim."
description="Checking this agent claim."
title="Loading claim"
/>
);
@@ -156,7 +156,7 @@ export function AuthsomeClaim({ token }: { token: string }) {
return (
<AuthFlowShell
description={`Confirm that ${data.identity} should be linked to ${data.email || "this account"}.`}
title="Claim identity"
title="Claim agent"
>
<form action={`/api/claim/${encodeURIComponent(token)}/confirm`} method="post">
<Button className="w-full" type="submit">
@@ -121,7 +121,7 @@ export function ConnectionDetailBody({
<KeyValue label="Status" value={data.status} />
<KeyValue label="Auth Type" value={data.auth_type} />
<KeyValue label="Principal ID" value={data.principal_id || "-"} />
<KeyValue label="Identity" value={data.identity || "-"} />
<KeyValue label="Agent" value={data.identity || "-"} />
<KeyValue label="Scopes" value={data.scopes.join(", ") || "-"} />
<KeyValue label="Token Type" value={data.token_type || "-"} />
<KeyValue label="Obtained" value={data.obtained_at || "-"} />
@@ -122,7 +122,7 @@ function GlobalConnectionsSection({
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle>Global Connections</CardTitle>
<CardDescription>Deployment-wide fallback connections available to accepted identities.</CardDescription>
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
</CardHeader>
<CardContent className="p-0">
{connections.length ? (
@@ -56,7 +56,7 @@ export const NAV_ITEMS: NavItem[] = [
{ id: "connections", href: "/connections", label: "Connections", icon: <Link2 /> },
{ id: "agents", href: "/agents", label: "Agents", icon: <UserRound /> },
{ id: "principals", href: "/principal", label: "Principals", icon: <Users />, adminOnly: true },
{ id: "audit", href: "/audit", label: "Audit Log", icon: <ClipboardList />, adminOnly: true },
{ id: "audit", href: "/audit", label: "Audit Log", icon: <ClipboardList /> },
{ id: "settings", href: "/settings", label: "Settings", icon: <Settings /> },
];
@@ -215,7 +215,7 @@ export function AppSidebar({
</SidebarMenu>
<SidebarSeparator />
<div className="px-2 py-1">
<div className="truncate text-sm font-medium">{data.account.email || data.account.identity}</div>
<div className="truncate text-sm font-medium">{data.account.email || data.account.agent}</div>
{data.account.roleLabel ? (
<div className="mt-0.5 text-xs text-muted-foreground">{data.account.roleLabel}</div>
) : null}
+68 -15
View File
@@ -2,16 +2,17 @@
import { UserRound } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import useSWR from "swr";
import { PageEmptyState, PageErrorState, PageLoadingState } from "@/components/dashboard/page-state";
import { ProviderSummary } from "@/components/dashboard/provider-views";
import { SectionHeader } from "@/components/dashboard/section-header";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DashboardData, PrincipalRow, fetchPrincipals } from "@/lib/authsome-api";
import { DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api";
export function DashboardView({ data }: { data: DashboardData }) {
const recentEvents = data.audit.events.slice(0, 5);
@@ -44,23 +45,23 @@ export function DashboardView({ data }: { data: DashboardData }) {
<div className="mb-4">
<h2 className="text-base font-semibold">Agents</h2>
</div>
{data.identities.length ? (
{data.agents.length ? (
<div className="grid gap-2">
{data.identities.map((identity) => (
{data.agents.map((agent) => (
<div
className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3"
key={identity.handle}
key={agent.handle}
>
<div className="flex items-center gap-3">
<UserRound className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{identity.handle}</span>
<span className="text-sm font-medium">{agent.handle}</span>
</div>
{identity.isActive ? <Badge variant="outline">Active</Badge> : null}
{agent.isActive ? <Badge variant="outline">Active</Badge> : null}
</div>
))}
</div>
) : (
<PageEmptyState title="No identities found" />
<PageEmptyState title="No agents found" />
)}
</section>
@@ -101,7 +102,7 @@ export function AgentsView({ data }: { data: DashboardData }) {
<SectionHeader description="Local Ed25519 key pairs (agents) claimed to this account." title="Agents" />
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
{data.identities.length ? (
{data.agents.length ? (
<Table>
<TableHeader>
<TableRow>
@@ -109,14 +110,14 @@ export function AgentsView({ data }: { data: DashboardData }) {
</TableRow>
</TableHeader>
<TableBody>
{data.identities.map((identity) => (
<TableRow key={identity.handle}>
{data.agents.map((agent) => (
<TableRow key={agent.handle}>
<TableCell>
<div className="flex items-center gap-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
<UserRound className="size-3.5 text-muted-foreground" />
</span>
<span className="font-medium">{identity.handle}</span>
<span className="font-medium">{agent.handle}</span>
</div>
</TableCell>
</TableRow>
@@ -182,12 +183,57 @@ export function PrincipalsView() {
}
export function AuditView({ data }: { data: DashboardData }) {
const [auditResult, setAuditResult] = useState<{
events: DashboardData["audit"]["events"];
nextCursor: string | null;
} | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [loadingMore, setLoadingMore] = useState(false);
const requestSequence = useRef(0);
const events = auditResult?.events ?? data.audit.events;
const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor;
const description = data.account.isAdmin
? "Recent administrative and credential events."
: "Recent account, identity, vault, and credential events for this principal.";
function nextRequestId(): number {
requestSequence.current += 1;
return requestSequence.current;
}
function isLatestRequest(requestId: number): boolean {
return requestSequence.current === requestId;
}
async function loadMore() {
if (!nextCursor) return;
const requestId = nextRequestId();
setLoadingMore(true);
setErrorMessage("");
try {
const result = await fetchAuditEvents({ cursor: nextCursor, limit: 50 });
if (!isLatestRequest(requestId)) return;
setAuditResult({
events: [...events, ...result.events],
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load more audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingMore(false);
}
}
}
return (
<div className="grid gap-5">
<SectionHeader description="Recent administrative and credential events." title="Audit Log" />
<SectionHeader description={description} title="Audit Log" />
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
{data.audit.events.length ? (
{events.length ? (
<Table>
<TableHeader>
<TableRow>
@@ -199,7 +245,7 @@ export function AuditView({ data }: { data: DashboardData }) {
</TableRow>
</TableHeader>
<TableBody>
{data.audit.events.map((event) => (
{events.map((event) => (
<TableRow key={event.eventId}>
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">{event.time}</TableCell>
<TableCell className="font-medium">{event.event}</TableCell>
@@ -225,6 +271,13 @@ export function AuditView({ data }: { data: DashboardData }) {
) : <PageEmptyState title="No audit events found" />}
</CardContent>
</Card>
{nextCursor ? (
<div className="flex justify-center">
<Button disabled={loadingMore} onClick={() => void loadMore()} type="button" variant="outline">
{loadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
);
}
+116 -10
View File
@@ -1,22 +1,48 @@
"use client";
import { Settings, ShieldCheck, Users, Vault } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ExternalLink, Info, KeyRound, Settings, ShieldCheck, Users, Vault } from "lucide-react";
import { SectionHeader } from "@/components/dashboard/section-header";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DashboardData } from "@/lib/authsome-api";
const SETTINGS_TABS = new Set(["account", "about", "security"]);
export function SettingsView({ data }: { data: DashboardData }) {
const searchParams = useSearchParams();
const requestedTab = searchParams.get("tab") || "account";
const defaultTab = SETTINGS_TABS.has(requestedTab) ? requestedTab : "account";
const passwordChanged = searchParams.get("password_changed") === "1";
const passwordError = searchParams.get("password_error");
return (
<div className="grid gap-5">
<SectionHeader description="Local daemon and account context." title="Settings" />
<div className="grid gap-4 lg:grid-cols-2">
<SettingsAccountCard data={data} />
<SettingsVaultCard data={data} />
<SettingsDaemonCard data={data} />
<SettingsSecurityCard data={data} />
</div>
<SectionHeader description="Account, runtime, and security context." title="Settings" />
<Tabs className="gap-5" defaultValue={defaultTab}>
<TabsList className="grid h-auto w-full grid-cols-3 md:w-fit">
<TabsTrigger value="account">General</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="account">
<SettingsAccountCard data={data} />
<SettingsVaultCard data={data} />
</TabsContent>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="about">
<SettingsDaemonCard data={data} />
<SettingsAboutCard />
</TabsContent>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="security">
<SettingsSecurityCard data={data} />
<SettingsPasswordCard passwordChanged={passwordChanged} passwordError={passwordError} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -71,8 +97,41 @@ function SettingsDaemonCard({ data }: { data: DashboardData }) {
</CardHeader>
<CardContent className="grid gap-4">
<SettingsKeyValue label="Version" value={data.version} />
<SettingsKeyValue label="Last Activity" value={data.lastActivity || "-"} />
<SettingsKeyValue label="Active Identity" value={data.account.identity || "-"} />
<SettingsKeyValue label="Latest Token Expiry" value={data.latestTokenExpiry || "-"} />
</CardContent>
</Card>
);
}
function SettingsAboutCard() {
return (
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="size-4 text-muted-foreground" />
About
</CardTitle>
<CardDescription>Project resources and release references.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Link
className={buttonVariants({ size: "sm", variant: "outline" })}
href="https://authsome.ai/docs"
rel="noreferrer"
target="_blank"
>
Docs
<ExternalLink />
</Link>
<Link
className={buttonVariants({ size: "sm", variant: "outline" })}
href="https://github.com/agentrhq/authsome/releases"
rel="noreferrer"
target="_blank"
>
Releases
<ExternalLink />
</Link>
</CardContent>
</Card>
);
@@ -97,6 +156,53 @@ function SettingsSecurityCard({ data }: { data: DashboardData }) {
);
}
function SettingsPasswordCard({
passwordChanged,
passwordError,
}: {
passwordChanged: boolean;
passwordError: string | null;
}) {
return (
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-muted-foreground" />
Password
</CardTitle>
<CardDescription>Hosted account credential.</CardDescription>
</CardHeader>
<CardContent>
<form action="/api/auth/password" className="grid gap-4" method="post">
<input name="next" type="hidden" value="/settings?tab=security" />
{passwordChanged ? (
<div className="rounded-lg border border-emerald-800 bg-emerald-950/30 px-3 py-2 text-sm text-emerald-300">
Password updated.
</div>
) : null}
{passwordError ? (
<div className="rounded-lg border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{passwordError}
</div>
) : null}
<label className="grid gap-1 text-sm font-medium">
Current password
<Input autoComplete="current-password" name="current_password" required type="password" />
</label>
<label className="grid gap-1 text-sm font-medium">
New password
<Input autoComplete="new-password" minLength={8} name="new_password" required type="password" />
</label>
<Button className="w-fit" size="sm" type="submit">
<KeyRound />
Change password
</Button>
</form>
</CardContent>
</Card>
);
}
function SettingsKeyValue({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
+54 -16
View File
@@ -34,7 +34,7 @@ export type GlobalConnectionRow = ConnectionRow & {
accountLabel: string | null;
};
export type IdentityRow = {
export type AgentRow = {
handle: string;
isActive: boolean;
};
@@ -42,6 +42,7 @@ export type IdentityRow = {
export type AuditRow = {
eventId: string;
time: string;
eventName: string;
event: string;
source: string;
actor: string;
@@ -50,6 +51,18 @@ export type AuditRow = {
metadata: Record<string, unknown>;
};
export type AuditEventsQuery = {
cursor?: string | null;
limit?: number;
};
export type AuditEventsData = {
scope: "global" | "principal";
nextCursor: string | null;
events: AuditRow[];
total: number;
};
export type DashboardData = {
version: string;
account: {
@@ -57,15 +70,15 @@ export type DashboardData = {
roleLabel: string | null;
isAdmin: boolean;
principalId: string | null;
identity: string | null;
agent: string | null;
};
stats: DashboardStats;
lastActivity: string;
latestTokenExpiry: string;
providers: ProviderView[];
connectedProviders: ProviderView[];
connections: ConnectionRow[];
globalConnections: GlobalConnectionRow[];
identities: IdentityRow[];
agents: AgentRow[];
vault: {
vaultId: string | null;
handle: string;
@@ -73,6 +86,8 @@ export type DashboardData = {
};
audit: {
canView: boolean;
scope: "global" | "principal";
nextCursor: string | null;
total: number;
events: AuditRow[];
};
@@ -218,6 +233,8 @@ type ConnectionsResponse = {
type AuditResponse = {
entries: Array<Record<string, unknown>>;
next_cursor?: string | null;
scope?: "global" | "principal";
};
export type PrincipalRow = {
@@ -450,7 +467,7 @@ function formatRelative(value: string | null | undefined): string | null {
return direction === "in" ? `in ${label}` : `${label} ago`;
}
function lastActivity(data: ConnectionsResponse): string {
function latestTokenExpiry(data: ConnectionsResponse): string {
const latest = data.connections
.flatMap((group) => group.connections)
.map((connection) => connection.expires_at)
@@ -484,6 +501,7 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] {
return {
eventId: String(entry.event_id || `${entry.timestamp || "event"}-${index}`),
time: formatAuditTime(entry.timestamp),
eventName: String(entry.event || "audit_event"),
event: humanize(entry.event),
source: String(entry.source || "internal"),
actor: String(entry.identity || entry.principal_id || "system"),
@@ -494,6 +512,24 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] {
});
}
function auditQueryString(query: AuditEventsQuery = {}): string {
const params = new URLSearchParams();
params.set("limit", String(query.limit ?? 50));
if (query.cursor) params.set("cursor", query.cursor);
return params.toString();
}
export async function fetchAuditEvents(query: AuditEventsQuery = {}): Promise<AuditEventsData> {
const data = await requestJson<AuditResponse>(`/api/audit/events?${auditQueryString(query)}`);
const events = buildAuditRows(data.entries);
return {
scope: data.scope ?? "principal",
nextCursor: data.next_cursor ?? null,
events,
total: events.length,
};
}
function roleLabel(role: string | undefined): string | null {
if (!role) {
return null;
@@ -508,15 +544,15 @@ export async function fetchDashboard(): Promise<DashboardData> {
requestJson<ConnectionsResponse>("/api/connections"),
]);
const isAdmin = whoami.principal_role === "admin";
const audit = isAdmin ? await requestJson<AuditResponse>("/api/audit/events?limit=100") : { entries: [] };
const audit = await fetchAuditEvents({ limit: 100 });
const providers = buildProviders(connectionsData);
const connections = buildConnectionRows(connectionsData, providers);
const globalConnections = buildGlobalConnectionRows(connectionsData);
const connectedProviders = providers.filter((provider) => provider.status !== "available");
const activeIdentity = whoami.identity || whoami.active_identity || null;
const identityHandles = new Set(identitiesData.identities.map((identity) => identity.handle));
if (activeIdentity) {
identityHandles.add(activeIdentity);
const activeAgent = whoami.identity || whoami.active_identity || null;
const agentHandles = new Set(identitiesData.identities.map((identity) => identity.handle));
if (activeAgent) {
agentHandles.add(activeAgent);
}
return {
@@ -526,7 +562,7 @@ export async function fetchDashboard(): Promise<DashboardData> {
roleLabel: roleLabel(whoami.principal_role),
isAdmin,
principalId: whoami.principal_id || null,
identity: activeIdentity,
agent: activeAgent,
},
stats: {
connected: connectedProviders.length,
@@ -534,21 +570,23 @@ export async function fetchDashboard(): Promise<DashboardData> {
oauth: connectedProviders.filter((provider) => provider.authType === "oauth2").length,
apiKey: connectedProviders.filter((provider) => provider.authType === "api_key").length,
},
lastActivity: lastActivity(connectionsData),
latestTokenExpiry: latestTokenExpiry(connectionsData),
providers,
connectedProviders: connectedProviders.slice(0, 6),
connections,
globalConnections,
identities: Array.from(identityHandles, (handle) => ({ handle, isActive: handle === activeIdentity })),
agents: Array.from(agentHandles, (handle) => ({ handle, isActive: handle === activeAgent })),
vault: {
vaultId: whoami.vault_id || null,
handle: "default",
isDefault: true,
},
audit: {
canView: isAdmin,
total: audit.entries.length,
events: buildAuditRows(audit.entries),
canView: true,
scope: audit.scope,
nextCursor: audit.nextCursor,
total: audit.total,
events: audit.events,
},
};
}