mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
Merge branch 'main' into 417-comparison-with-one-cli-secrets-manager
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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", ...}`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -147,7 +147,7 @@ class TestAuthServiceRefreshLogs:
|
||||
try:
|
||||
yield log
|
||||
finally:
|
||||
log.shutdown()
|
||||
await log.async_shutdown()
|
||||
await store.close()
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user