From 63bd4c90aa10e31d4b11ad9b190f00f1e1e1a316 Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Fri, 29 May 2026 13:18:41 +0530 Subject: [PATCH 1/4] refactor!: unify local and hosted into a single deployment flow The daemon branched on AUTHSOME_DEPLOYMENT_MODE and ran two parallel implementations of nearly every ownership concept. "Local and hosted behave the same" was asserted in prose (ADR 0006) but never enforced in code, so the paths were free to drift, and the synthetic local principal let a second local identity silently inherit the admin principal. Collapse to one flow, identical for every deployment: authsome init registers an identity and gets back a browser claim URL; the user registers email+password (first principal becomes admin); that principal confirms the claim; PoP calls are then authorized. - Remove AUTHSOME_DEPLOYMENT_MODE, get_deployment_mode(), LOCAL_PRINCIPAL_EMAIL, the Local*/Hosted* resolver and bootstrap classes, and the AuthService(deployment_mode=...) parameter. - OwnershipResolver and IdentityBootstrapService become single concrete classes (the former hosted, claim-based implementations). - Admin gating is purely role-based: non-admin principals are blocked in every deployment (previously implicitly allowed in local mode). - The server UI always requires a hosted browser session; remove the vestigial HealthResponse.mode field. - CLI ensure_identity_ready was already mode-agnostic; it now prints the claim URL to stderr for headless use. Add ADR 0007 recording the decision; amend ADR 0006 and CONTEXT.md. BREAKING CHANGE: existing local installs have an unclaimed identity under local@authsome.internal and are rejected until the user registers a principal (email+password) and claims the identity. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: d31cf246f6f2 --- CONTEXT.md | 6 +- docs/adr/0006-principal-roles-admin-user.md | 2 +- docs/adr/0007-unified-deployment-flow.md | 36 ++++++++++++ src/authsome/cli/client.py | 29 +++++++--- src/authsome/server/credential_service.py | 25 ++++---- src/authsome/server/dependencies.py | 40 ++++--------- src/authsome/server/identity_bootstrap.py | 56 ++++++------------ src/authsome/server/ownership.py | 58 +++---------------- src/authsome/server/routes/_deps.py | 18 +----- src/authsome/server/routes/health.py | 6 -- src/authsome/server/routes/ui.py | 43 +++++--------- src/authsome/server/schemas.py | 1 - tests/auth/test_service_provider_clients.py | 20 +++---- .../auth/test_service_provider_definitions.py | 2 + tests/server/test_audit_events.py | 13 +---- tests/server/test_auth_sessions.py | 13 +---- tests/server/test_identity_bootstrap.py | 28 ++------- tests/server/test_ownership.py | 28 +++------ tests/server/test_pop_auth.py | 47 ++++++++------- .../server/test_provider_operation_policy.py | 4 -- tests/server/test_ui_dashboard.py | 38 ++++-------- tests/server/test_ui_sessions.py | 10 ---- 22 files changed, 188 insertions(+), 335 deletions(-) create mode 100644 docs/adr/0007-unified-deployment-flow.md diff --git a/CONTEXT.md b/CONTEXT.md index 50d78993..ba657f8e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -200,7 +200,7 @@ _Avoid_: vault_name, vault_handle **VaultHandle**: Human-readable name for a Vault (e.g., `default`). Used in UIs and CLI; the VaultId is authoritative in storage. -**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created during `authsome init --email`. Vault access is gated until the claim is accepted. +**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome init` initiates. Vault access is gated until the claim is accepted. _Avoid_: Claim, IdentityRegistration (as claim), join request **ClaimStatus**: Lifecycle state: `pending` → `accepted` | `rejected`. @@ -209,9 +209,7 @@ _Avoid_: Claim, IdentityRegistration (as claim), join request ## Initialization & Claim Flow -**Local mode**: `authsome init` creates an Identity, auto-accepts its claim under the implicit local Principal, and creates the default Vault. No email required. - -**Hosted mode**: `authsome init --email manoj@example.com` creates an Identity, creates or finds the Principal by email, and registers an `IdentityClaimRecord` with `claim_status = pending`. A human reviews the claim in the UI and accepts or rejects it. All vault operations return `403` until the claim is accepted. +There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome init` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use). --- diff --git a/docs/adr/0006-principal-roles-admin-user.md b/docs/adr/0006-principal-roles-admin-user.md index 16f8d658..36f16251 100644 --- a/docs/adr/0006-principal-roles-admin-user.md +++ b/docs/adr/0006-principal-roles-admin-user.md @@ -8,7 +8,7 @@ Principals need an authorization tier to gate deployment-level operations (audit **Separate `principal_roles` table** — considered for future extensibility (multiple roles per principal). Rejected as premature: the role model is binary and a join adds complexity without benefit today. -**Default admin account created at server init** — would require deciding on credentials before any real user exists. Rejected in favour of first-user-becomes-admin, which is zero-config and correct for both local and hosted deployments. +**Default admin account created at server init** — would require deciding on credentials before any real user exists. Rejected in favour of first-user-becomes-admin: the first principal to register is admin, all subsequent principals are users. (Originally justified as "zero-config" for an implicit local principal; that implicit-principal path was later removed when local and hosted were unified into a single registration + claim flow — see ADR 0007. First-principal-is-admin remains the rule, now reached the same way in every deployment.) ## Consequences diff --git a/docs/adr/0007-unified-deployment-flow.md b/docs/adr/0007-unified-deployment-flow.md new file mode 100644 index 00000000..a1fe4a81 --- /dev/null +++ b/docs/adr/0007-unified-deployment-flow.md @@ -0,0 +1,36 @@ +# Unified deployment flow: one registration + claim path for every deployment + +The daemon previously branched on a deployment mode (`AUTHSOME_DEPLOYMENT_MODE`, defaulting to `local`) selected at startup, and ran two parallel implementations of nearly every ownership concept: + +- **Local**: every Identity collapsed onto one synthetic Principal (`local@authsome.internal`); no claim was required; the Ed25519 PoP key was the only credential. Implemented by `LocalOwnershipResolver` and `LocalIdentityBootstrapService`. +- **Hosted**: each account was its own Principal authenticated by email+password in the browser; an Identity had to be explicitly claimed and accepted. Implemented by `HostedOwnershipResolver` and `HostedIdentityBootstrapService`. + +Two code paths doubled the test surface, scattered `if get_deployment_mode() == "hosted"` branches across `dependencies.py`, `routes/_deps.py`, `routes/ui.py`, `routes/health.py`, and `credential_service.py`, and — most importantly — meant "local and hosted behave the same" was asserted in prose but never enforced in code, so the two paths were free to drift. The synthetic local Principal also had a latent wart: a second local Identity silently inherited the *admin* Principal and its Vault, which the hosted model would never allow. + +## Decision + +Collapse to a single flow, identical for every deployment. There is no deployment mode. + +1. `authsome init` generates the Ed25519 Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. +2. The user opens the claim URL and **registers (or logs in) with email + password**. The first Principal created on a server becomes **admin** (ADR 0006); all subsequent Principals are users. +3. The authenticated Principal **confirms the claim**, binding the Identity to the Principal and creating the Principal's default Vault. +4. PoP-authenticated calls are then authorized; `OwnershipResolver.resolve` requires an *accepted* claim. + +The one irreducible difference between a single-machine and a networked deployment — *who authenticates the claiming Principal* — is resolved by always requiring the email+password browser step. Local is no longer special-cased; the local user is simply the first to sign up and therefore the admin. + +The CLI (`AuthsomeApiClient.ensure_identity_ready`) was already mode-agnostic: it reacts to `claim_required` by opening the browser and polling, so it required no behavioural change. It now prints the claim URL to stderr so headless users can complete the step manually. + +This supersedes the local/hosted split described in ADR 0003 (§ "local mode … hosted mode") and the "zero-config local" rationale in ADR 0006. + +## Considered alternatives + +**Single resolver with an injected claim-acceptance policy** (local auto-accepts, hosted requires a human). Keeps one code path while preserving local's zero-config experience. Rejected by explicit product decision: we want local and hosted to be byte-for-byte identical, with no second path or policy seam to maintain. + +**Keep two resolvers, only harden with tests + docs.** Smallest change, but leaves the drift risk and duplicate test surface in place. Rejected. + +## Consequences + +- `AUTHSOME_DEPLOYMENT_MODE`, `get_deployment_mode()`, `LOCAL_PRINCIPAL_EMAIL`, the `Local*`/`Hosted*` resolver and bootstrap classes, and the `AuthService(deployment_mode=...)` parameter are all removed. `OwnershipResolver` and `IdentityBootstrapService` are single concrete classes. +- Admin enforcement is purely role-based: `_ensure_admin_operation_allowed` / `_ensure_provider_client_mutation_allowed` raise for any non-admin Principal, in every deployment (previously non-admins were implicitly allowed in local mode because the sole local Principal was always admin). +- The server-rendered UI always requires a hosted browser session; the local filesystem-identity UI path is gone. `HealthResponse.mode` is removed. +- **Breaking change.** Existing local installs have an Identity registered with no accepted claim and data under the `local@authsome.internal` Principal/Vault. After upgrading, those Identities are rejected until the user registers a Principal (email+password) and claims the Identity. A migration that rebinds the existing local Vault to a freshly registered Principal is possible but is intentionally out of scope here. diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index d27e7a6f..3b0ba0c4 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -5,6 +5,7 @@ import asyncio import json import os +import sys import webbrowser from collections.abc import Mapping from pathlib import Path @@ -134,7 +135,12 @@ async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, return {"Authorization": f"{POP_AUTH_SCHEME} {token}"} async def ensure_identity_ready(self) -> RuntimeIdentity: - """Ensure the acting identity is registered and, in hosted mode, claimed.""" + """Ensure the acting identity is registered and claimed by a principal. + + A freshly registered identity must be claimed by a principal before it + can make authenticated calls; the daemon returns a browser claim URL + which is opened here while we poll for completion. + """ runtime = self._runtime_identity() if runtime.source is IdentitySource.ENV: return await self._ensure_env_identity_ready(runtime) @@ -159,10 +165,7 @@ async def ensure_identity_ready(self) -> RuntimeIdentity: status = await self.register_identity(identity.handle, identity.did) claim_url = status.get("claim_url") if claim_url: - try: - webbrowser.open(claim_url) - except Exception: - pass + self._open_claim_url(claim_url) await self._poll_claim_completion(identity.handle) identity = mark_claimed(self._home, identity.handle) return self._runtime_for_handle(identity.handle) @@ -186,13 +189,21 @@ async def _ensure_env_identity_ready(self, identity: RuntimeIdentity) -> Runtime status = await self.register_identity(identity.handle, identity.did) claim_url = status.get("claim_url") if claim_url: - try: - webbrowser.open(claim_url) - except Exception: - pass + self._open_claim_url(claim_url) await self._poll_claim_completion(identity.handle) return identity + def _open_claim_url(self, claim_url: str) -> None: + """Surface the browser claim URL (so headless users can open it) and try to launch it.""" + print( + f"Open this URL in your browser to register and claim this identity:\n {claim_url}", + file=sys.stderr, + ) + try: + webbrowser.open(claim_url) + except Exception: + pass + async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> dict[str, Any]: deadline = asyncio.get_running_loop().time() + timeout_seconds while True: diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 895b1fc7..c297cd72 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -82,14 +82,12 @@ def __init__( principal_id: str | None = None, principal_role: PrincipalRole = PrincipalRole.USER, vault_id: str | None = None, - deployment_mode: str = "local", ) -> None: self._vault = vault self._identity = identity self._principal_id = principal_id self._principal_role = principal_role self._vault_id = vault_id - self._deployment_mode = "hosted" if deployment_mode == "hosted" else "local" self._provider_definitions = provider_definitions self._bundled: dict[str, ProviderDefinition] = self._load_bundled_providers() @@ -201,22 +199,20 @@ async def remove_provider(self, name: str) -> bool: def _ensure_admin_operation_allowed(self, operation: str, provider: str) -> None: if self._principal_role == PrincipalRole.ADMIN: return - if self._deployment_mode == "hosted": - raise OperationNotAllowedError( - operation, - f"{operation} is not allowed in hosted deployments", - provider=provider, - ) + raise OperationNotAllowedError( + operation, + f"{operation} requires an admin principal", + provider=provider, + ) def _ensure_provider_client_mutation_allowed(self, provider: str) -> None: if self._principal_role == PrincipalRole.ADMIN: return - if self._deployment_mode == "hosted": - raise OperationNotAllowedError( - "login", - "provider client configuration is not allowed in hosted deployments", - provider=provider, - ) + raise OperationNotAllowedError( + "login", + "provider client configuration requires an admin principal", + provider=provider, + ) def _validate_provider(self, definition: ProviderDefinition) -> None: if not is_filesystem_safe(definition.name): @@ -765,7 +761,6 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non principal_id=self._principal_id, principal_role=self._principal_role, vault_id=vault_id, - deployment_mode=self._deployment_mode, provider_definitions=self._provider_definitions, ) meta_key = build_store_key(vault=vault_id, provider=provider, record_type="metadata") diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 011d1c22..e0ab7d5e 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path from typing import TYPE_CHECKING, Any @@ -18,12 +17,8 @@ from authsome.paths import get_server_home as _get_server_home from authsome.paths import get_server_log_path as _get_server_log_path from authsome.server.hosted_auth import HostedAccountService -from authsome.server.identity_bootstrap import ( - HostedIdentityBootstrapService, - IdentityBootstrapService, - LocalIdentityBootstrapService, -) -from authsome.server.ownership import HostedOwnershipResolver, LocalOwnershipResolver, OwnershipResolver +from authsome.server.identity_bootstrap import IdentityBootstrapService +from authsome.server.ownership import OwnershipResolver from authsome.server.secrets import load_master_secret, load_ui_session_signing_secret from authsome.server.store import ServerStore from authsome.server.store import create_server_store as _create_server_store @@ -58,12 +53,6 @@ def get_server_base_url() -> str: return build_server_base_url() -def get_deployment_mode() -> str: - """Return the daemon deployment mode.""" - mode = os.environ.get("AUTHSOME_DEPLOYMENT_MODE", "local").strip().lower() - return "hosted" if mode == "hosted" else "local" - - async def get_local_ui_identity(home: Path | None = None) -> str: """Resolve the local active identity handle for the server-rendered UI.""" identity = await current_from_home(home or get_authsome_home()) @@ -120,7 +109,6 @@ async def create_auth_service( vault=vault, identity=identity, vault_id=vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=store.provider_definitions, ) @@ -135,16 +123,10 @@ def create_hosted_account_service(store: ServerStore) -> HostedAccountService: def create_ownership_resolver(store: ServerStore) -> OwnershipResolver: - if get_deployment_mode() == "hosted": - return HostedOwnershipResolver( - principals=store.principals, - vaults=store.vaults, - claims=store.identity_claims, - bindings=store.principal_vault_bindings, - ) - return LocalOwnershipResolver( + return OwnershipResolver( principals=store.principals, vaults=store.vaults, + claims=store.identity_claims, bindings=store.principal_vault_bindings, ) @@ -156,11 +138,9 @@ def create_identity_bootstrap_service( store: ServerStore, server_base_url: str | None = None, ) -> IdentityBootstrapService: - if get_deployment_mode() == "hosted": - return HostedIdentityBootstrapService( - registry=identity_registry, - claims=store.identity_claims, - ui_sessions=ui_sessions, - server_base_url=server_base_url or get_server_base_url(), - ) - return LocalIdentityBootstrapService(registry=identity_registry) + return IdentityBootstrapService( + registry=identity_registry, + claims=store.identity_claims, + ui_sessions=ui_sessions, + server_base_url=server_base_url or get_server_base_url(), + ) diff --git a/src/authsome/server/identity_bootstrap.py b/src/authsome/server/identity_bootstrap.py index bf074182..f63f9f6d 100644 --- a/src/authsome/server/identity_bootstrap.py +++ b/src/authsome/server/identity_bootstrap.py @@ -1,8 +1,7 @@ -"""Deployment-specific identity bootstrap behavior.""" +"""Identity bootstrap: register identities and report their claim readiness.""" from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from authsome.identity.principal import ClaimStatus @@ -22,7 +21,7 @@ class IdentityBootstrapStatus: claim_url: str = "" def to_payload(self) -> dict[str, str]: - """Serialize a route payload without exposing deployment-specific logic.""" + """Serialize a route payload.""" payload = { "status": "registered", "identity": self.identity, @@ -36,40 +35,13 @@ def to_payload(self) -> dict[str, str]: return payload -class IdentityBootstrapService(ABC): - """Register identities and report their readiness state.""" - - def __init__(self, *, registry: IdentityRegistry) -> None: - self._registry = registry - - async def register_identity(self, *, handle: str, did: str) -> IdentityBootstrapStatus: - registration = await self._registry.register(handle=handle, did=did) - return await self._build_status(registration) - - async def get_identity_status(self, *, handle: str) -> IdentityBootstrapStatus | None: - registration = await self._registry.resolve(handle) - if registration is None: - return None - return await self._build_status(registration) - - @abstractmethod - async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: - """Return a normalized readiness status for a registered identity.""" - - -class LocalIdentityBootstrapService(IdentityBootstrapService): - """Bootstrap service for local deployments with implicit ownership.""" - - async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: - return IdentityBootstrapStatus( - identity=registration.handle, - did=registration.did, - registration_status="registered", - ) +class IdentityBootstrapService: + """Register identities and report their claim readiness state. - -class HostedIdentityBootstrapService(IdentityBootstrapService): - """Bootstrap service for hosted deployments requiring explicit claims.""" + Every identity must be claimed by an authenticated principal. A freshly + registered identity is reported as ``claim_required`` with a browser claim + URL; once a principal claims and accepts it the status becomes ``claimed``. + """ def __init__( self, @@ -79,11 +51,21 @@ def __init__( ui_sessions: UiSessionStore, server_base_url: str, ) -> None: - super().__init__(registry=registry) + self._registry = registry self._claims = claims self._ui_sessions = ui_sessions self._server_base_url = server_base_url.rstrip("/") + async def register_identity(self, *, handle: str, did: str) -> IdentityBootstrapStatus: + registration = await self._registry.register(handle=handle, did=did) + return await self._build_status(registration) + + async def get_identity_status(self, *, handle: str) -> IdentityBootstrapStatus | None: + registration = await self._registry.resolve(handle) + if registration is None: + return None + return await self._build_status(registration) + async def _build_status(self, registration: IdentityRegistration) -> IdentityBootstrapStatus: claim = await self._claims.resolve(registration.handle) if claim is None: diff --git a/src/authsome/server/ownership.py b/src/authsome/server/ownership.py index 64d550b5..8d560307 100644 --- a/src/authsome/server/ownership.py +++ b/src/authsome/server/ownership.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from authsome.identity.principal import ClaimStatus, PrincipalRole @@ -13,8 +12,6 @@ VaultRegistry, ) -LOCAL_PRINCIPAL_EMAIL = "local@authsome.internal" - @dataclass(frozen=True) class ResolvedOwnership: @@ -26,18 +23,6 @@ class ResolvedOwnership: role: PrincipalRole -class OwnershipResolver(ABC): - """Resolve principal and vault context for an acting identity.""" - - @abstractmethod - async def resolve(self, *, identity: str) -> ResolvedOwnership: - """Resolve the principal and vault for an identity.""" - - async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: - """Claim an identity for an authenticated principal.""" - raise NotImplementedError - - async def ensure_principal_default_vault( *, principal_id: str, @@ -52,42 +37,14 @@ async def ensure_principal_default_vault( return binding.vault_id -class LocalOwnershipResolver(OwnershipResolver): - """Ownership resolver for local deployments with implicit ownership.""" - - def __init__( - self, - *, - principals: PrincipalRegistry, - vaults: VaultRegistry, - bindings: PrincipalVaultBindingRegistry, - ) -> None: - self._principals = principals - self._vaults = vaults - self._bindings = bindings - - async def resolve(self, *, identity: str) -> ResolvedOwnership: - principal = await self._principals.get_by_email(LOCAL_PRINCIPAL_EMAIL) - if principal is None: - principal = await self._principals.create_by_email(LOCAL_PRINCIPAL_EMAIL) - vault_id = await ensure_principal_default_vault( - principal_id=principal.principal_id, - vaults=self._vaults, - bindings=self._bindings, - ) - return ResolvedOwnership( - identity=identity, - principal_id=principal.principal_id, - vault_id=vault_id, - role=principal.role, - ) - - async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: - return await self.resolve(identity=identity) - +class OwnershipResolver: + """Resolve principal and vault context for an acting identity. -class HostedOwnershipResolver(OwnershipResolver): - """Ownership resolver for hosted deployments with explicit claims.""" + Every identity must be claimed by a principal and the claim accepted before + vault access is granted. This is the single resolution path for all + deployments — the claiming principal is authenticated out of band (browser + email+password), and the first principal created on a server is admin. + """ def __init__( self, @@ -120,6 +77,7 @@ async def resolve(self, *, identity: str) -> ResolvedOwnership: ) async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: + """Claim an identity for an authenticated principal and accept it.""" vault_id = await ensure_principal_default_vault( principal_id=principal_id, vaults=self._vaults, diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 39b41a40..11b64d0f 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -5,11 +5,9 @@ from fastapi import HTTPException, Request from authsome.auth.sessions import AuthSessionStore -from authsome.identity import current_from_home from authsome.identity.principal import PrincipalRole from authsome.identity.proof import POP_AUTH_SCHEME, ProofValidationError, validate_proof_jwt from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.store.repositories import VaultRegistry from authsome.server.ui_sessions import UiSessionStore @@ -33,7 +31,6 @@ async def get_auth_service( principal_id=resolved.principal_id, principal_role=resolved.role, vault_id=resolved.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) @@ -52,7 +49,6 @@ async def get_auth_service( principal_id=principal_id, principal_role=principal.role, vault_id=binding.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) @@ -171,19 +167,7 @@ def get_ui_sessions(request: Request) -> UiSessionStore: async def resolve_ui_request_identity(request: Request) -> str | None: - """Resolve the identity bound to a browser UI request.""" - if get_deployment_mode() != "hosted": - identity = await current_from_home(request.app.state.store.home) - request.state.ui_identity = identity.handle - try: - resolved = await request.app.state.ownership_resolver.resolve(identity=identity.handle) - request.state.ui_principal_id = resolved.principal_id - request.state.ui_principal_role = resolved.role - except ValueError: - request.state.ui_principal_id = None - request.state.ui_principal_role = None - return identity.handle - + """Resolve the principal bound to a browser UI request via its session cookie.""" cookie_value = request.cookies.get(UI_SESSION_COOKIE_NAME) if not cookie_value: return None diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index eb6e1ad8..d2872a03 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -2,13 +2,10 @@ from __future__ import annotations -from typing import Literal, cast - from fastapi import APIRouter, Depends, Request from authsome import __version__ from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import get_protected_auth_service, get_server_base_url from authsome.server.schemas import HealthResponse, ReadyResponse from authsome.utils import connection_is_active @@ -26,13 +23,10 @@ def _describe_vault_encryption(vault) -> tuple[str, str]: @router.get("/health", response_model=HealthResponse) def health(request: Request) -> HealthResponse: - mode = get_deployment_mode() - response_mode = cast(Literal["local", "hosted"], mode if mode == "hosted" else "local") effective_source, backend_description = _describe_vault_encryption(request.app.state.vault) return HealthResponse( status="ok", version=__version__, - mode=response_mode, configured_encryption_mode=effective_source, effective_encryption_source=effective_source, encryption_backend=backend_description, diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 1283d638..df0d45b3 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -24,7 +24,6 @@ from authsome.auth.sessions import AuthSession, AuthSessionStore from authsome.identity.principal import PrincipalRole from authsome.server.credential_service import AuthService -from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import ( UI_SESSION_COOKIE_NAME, get_auth_service, @@ -68,27 +67,19 @@ def _field_payloads(session: AuthSession) -> list[dict[str, Any]]: return [dict(field) for field in fields] -def _is_hosted_ui() -> bool: - return get_deployment_mode() == "hosted" - - def _ui_cookie_secure(server_base_url: str) -> bool: return server_base_url.startswith("https://") def _ui_policy(request: Request, auth: AuthService | None = None) -> dict[str, Any]: - hosted = _is_hosted_ui() role = auth.principal_role if auth is not None else getattr(request.state, "ui_principal_role", None) - show_provider_client_details = not hosted or role == PrincipalRole.ADMIN + show_provider_client_details = role == PrincipalRole.ADMIN return { - "ui_mode": "hosted" if hosted else "local", "show_provider_client_details": show_provider_client_details, "provider_management_label": ( - "OAuth application managed by Authsome" - if hosted and not show_provider_client_details - else "OAuth Application" + "OAuth Application" if show_provider_client_details else "OAuth application managed by Authsome" ), - "show_hosted_identity": hosted, + "show_hosted_identity": True, } @@ -109,13 +100,10 @@ async def _resolve_ui_auth(request: Request, *, next_url: str | None = None) -> if auth is not None: return auth - if _is_hosted_ui(): - target = _hosted_auth_next_url(next_url or request.query_params.get("next") or request.url.path) - if request.method == "GET" and request.url.path == "/": - raise UiAuthRequiredError(_hosted_auth_page_response(request.app.state.ui_sessions, next_url=target)) - raise UiAuthRequiredError(RedirectResponse(url=_hosted_auth_entry_url(target), status_code=303)) - - raise UiAuthRequiredError(_ui_session_expired_response()) + target = _hosted_auth_next_url(next_url or request.query_params.get("next") or request.url.path) + if request.method == "GET" and request.url.path == "/": + raise UiAuthRequiredError(_hosted_auth_page_response(request.app.state.ui_sessions, next_url=target)) + raise UiAuthRequiredError(RedirectResponse(url=_hosted_auth_entry_url(target), status_code=303)) def require_ui_auth(next_url: str | None = None) -> Callable[[Request], Awaitable[AuthService]]: @@ -290,7 +278,6 @@ async def _provider_connection_groups( identity=identity, principal_id=principal_id, vault_id=binding.vault_id, - deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, ) provider_connections = next( @@ -503,11 +490,8 @@ async def identity_page( request: Request, auth: AuthService = Depends(require_ui_auth("/identity")), ) -> Response: - if _is_hosted_ui(): - claims = await request.app.state.identity_claim_registry.list_for_principal(request.state.ui_principal_id) - identities = [{"handle": claim.identity_handle, "is_active": False} for claim in claims] - else: - identities = [{"handle": auth.identity, "is_active": True}] + claims = await request.app.state.identity_claim_registry.list_for_principal(request.state.ui_principal_id) + identities = [{"handle": claim.identity_handle, "is_active": False} for claim in claims] return templates.TemplateResponse( request, "identity.html", @@ -532,7 +516,7 @@ async def app_detail( redirect_uri = build_callback_url(server_base_url) api_url = _provider_api_url_label(provider) policy = _ui_policy(request, auth) - if not policy["show_provider_client_details"] and _is_hosted_ui(): + if not policy["show_provider_client_details"]: return templates.TemplateResponse( request, "app_detail_managed.html", @@ -643,8 +627,7 @@ async def connect_app( session.payload["force"] = force session.payload["callback_url_override"] = build_callback_url(server_base_url) session.payload["return_url"] = f"{server_base_url.rstrip('/')}/apps/{provider_name}" - if _is_hosted_ui(): - session.payload["ui_session_required"] = True + session.payload["ui_session_required"] = True if not force: try: @@ -690,7 +673,7 @@ async def configure_provider( """Open the provider configuration flow for deployment-scoped credentials.""" provider = await auth.get_provider(provider_name) policy = _ui_policy(request, auth) - if provider.auth_type != AuthType.OAUTH2 or (not policy["show_provider_client_details"] and _is_hosted_ui()): + if provider.auth_type != AuthType.OAUTH2 or not policy["show_provider_client_details"]: return _redirect(request, f"/apps/{provider_name}") session = await sessions.create( @@ -750,7 +733,7 @@ async def claim_identity_page( return _ui_session_expired_response(status_code=404) await resolve_ui_request_identity(request) - if _is_hosted_ui() and getattr(request.state, "ui_principal_id", None) is None: + if getattr(request.state, "ui_principal_id", None) is None: return HTMLResponse(pages.hosted_claim_auth_page(token=token, identity=pending.identity)) email = getattr(request.state, "ui_email", None) or "this account" diff --git a/src/authsome/server/schemas.py b/src/authsome/server/schemas.py index 23f4d746..c4900019 100644 --- a/src/authsome/server/schemas.py +++ b/src/authsome/server/schemas.py @@ -15,7 +15,6 @@ class HealthResponse(BaseModel): status: Literal["ok"] version: str - mode: Literal["local", "hosted"] = "local" configured_encryption_mode: str | None = None effective_encryption_source: str | None = None encryption_backend: str | None = None diff --git a/tests/auth/test_service_provider_clients.py b/tests/auth/test_service_provider_clients.py index 646d5f73..e7861c4c 100644 --- a/tests/auth/test_service_provider_clients.py +++ b/tests/auth/test_service_provider_clients.py @@ -81,7 +81,7 @@ async def test_get_provider_client_reads_from_server_scope() -> None: async def test_save_inputs_persists_provider_client_to_server_scope() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN) session = _make_session(flow_type=FlowType.PKCE) await service.save_inputs( @@ -107,7 +107,7 @@ async def test_save_inputs_persists_provider_client_to_server_scope() -> None: async def test_save_inputs_with_scopes_only_writes_server_record() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN) session = _make_session(flow_type=FlowType.PKCE) await service.save_inputs(session, {"scopes": "repo,read:user"}) @@ -160,7 +160,7 @@ async def test_pkce_client_credentials_prompt_id_then_secret() -> None: async def test_update_provider_configuration_persists_default_scopes_when_omitted() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN) with mock.patch.object(service, "get_provider", new=mock.AsyncMock(return_value=_make_provider())): changed = await service.update_provider_configuration( @@ -180,7 +180,7 @@ async def test_update_provider_configuration_persists_default_scopes_when_omitte async def test_update_provider_configuration_persists_submitted_scopes() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN) with mock.patch.object(service, "get_provider", new=mock.AsyncMock(return_value=_make_provider())): changed = await service.update_provider_configuration( @@ -211,13 +211,11 @@ async def put_value(key: str, value: str, *, collection: str) -> None: identity=None, principal_id="principal_admin", principal_role=PrincipalRole.ADMIN, - deployment_mode="hosted", ) identity_service = _service( vault, identity="steady-wisely-boldly-0042", principal_id="principal_user", - deployment_mode="hosted", ) provider = _make_provider() @@ -259,7 +257,7 @@ async def test_begin_login_flow_reuses_server_scopes() -> None: async def test_resume_login_flow_saves_dcr_client_record_to_server_scope() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042") + service = _service(vault, identity="steady-wisely-boldly-0042", principal_role=PrincipalRole.ADMIN) session = _make_session(flow_type=FlowType.DCR_PKCE) session.payload["base_url"] = "https://api.github.example" @@ -308,7 +306,7 @@ async def test_resume_login_flow_saves_dcr_client_record_to_server_scope() -> No async def test_hosted_save_inputs_rejects_shared_client_mutation() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.PKCE) with pytest.raises(OperationNotAllowedError): @@ -322,7 +320,7 @@ async def test_hosted_save_inputs_rejects_shared_client_mutation() -> None: async def test_hosted_save_inputs_rejects_scopes_only_server_write() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.PKCE) with pytest.raises(OperationNotAllowedError): @@ -333,7 +331,7 @@ async def test_hosted_save_inputs_rejects_scopes_only_server_write() -> None: async def test_hosted_resume_login_flow_rejects_dcr_client_persistence() -> None: vault = mock.AsyncMock() vault.get.return_value = None - service = _service(vault, identity="steady-wisely-boldly-0042", deployment_mode="hosted") + service = _service(vault, identity="steady-wisely-boldly-0042") session = _make_session(flow_type=FlowType.DCR_PKCE) connection = ConnectionRecord( @@ -376,8 +374,8 @@ async def test_revoke_local_deletes_shared_client_and_all_identity_connections(t vault, identity="steady-wisely-boldly-0042", principal_id="principal_1", + principal_role=PrincipalRole.ADMIN, vault_id=primary_vault.vault_id, - deployment_mode="local", provider_definitions=store.provider_definitions, ) diff --git a/tests/auth/test_service_provider_definitions.py b/tests/auth/test_service_provider_definitions.py index 188d0db1..08883bdf 100644 --- a/tests/auth/test_service_provider_definitions.py +++ b/tests/auth/test_service_provider_definitions.py @@ -8,6 +8,7 @@ from authsome.auth.models.connection import ProviderClientRecord from authsome.auth.models.enums import AuthType, FlowType from authsome.auth.models.provider import ApiKeyConfig, ProviderDefinition +from authsome.identity.principal import PrincipalRole from authsome.server.credential_service import AuthService from authsome.server.store import create_server_store from authsome.vault import Vault @@ -31,6 +32,7 @@ async def test_custom_provider_definition_is_stored_in_store_not_vault(tmp_path: service = AuthService( vault=vault, identity="steady-wisely-boldly-0042", + principal_role=PrincipalRole.ADMIN, vault_id="vault_test", provider_definitions=store.provider_definitions, ) diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index 80c69487..9be7453c 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -12,12 +12,6 @@ from tests.server.test_pop_auth import _auth_header -def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: - identity = create_identity(tmp_path, handle) - response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) - assert response.status_code == 200 - - def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> None: identity = create_identity(tmp_path, handle) response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) @@ -34,10 +28,9 @@ def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: s def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="dev@example.com") whoami = client.get("/whoami", headers=_auth_header(tmp_path, "GET", "/whoami")).json() emit_event( "login", @@ -61,12 +54,11 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}} body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="dev@example.com") posted = client.post( "/audit/events", content=body, @@ -92,7 +84,6 @@ def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path def test_hosted_user_cannot_query_audit_events(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") diff --git a/tests/server/test_auth_sessions.py b/tests/server/test_auth_sessions.py index a26b4a28..b855f7c2 100644 --- a/tests/server/test_auth_sessions.py +++ b/tests/server/test_auth_sessions.py @@ -9,6 +9,7 @@ from authsome.identity import create_identity from authsome.identity.proof import create_proof_jwt from authsome.server.app import create_app +from tests.server.test_pop_auth import register_and_claim_identity def _auth_header( @@ -34,7 +35,6 @@ def _auth_header( def test_get_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) owner = create_identity(tmp_path, "steady-wisely-boldly-0042") stranger = create_identity(tmp_path, "rapid-brightly-firmly-0007") app = create_app() @@ -42,11 +42,7 @@ def test_get_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None with TestClient(app) as client: owner_registration = client.post("/identities/register", json={"handle": owner.handle, "did": owner.did}) assert owner_registration.status_code == 200 - stranger_registration = client.post( - "/identities/register", - json={"handle": stranger.handle, "did": stranger.did}, - ) - assert stranger_registration.status_code == 200 + register_and_claim_identity(client, tmp_path, stranger.handle, email="stranger@example.com") session = asyncio.run( client.app.state.auth_sessions.create( provider="github", @@ -73,7 +69,6 @@ def test_get_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None def test_resume_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) owner = create_identity(tmp_path, "steady-wisely-boldly-0042") stranger = create_identity(tmp_path, "rapid-brightly-firmly-0007") app = create_app() @@ -113,13 +108,11 @@ def test_resume_session_rejects_other_identity(monkeypatch, tmp_path: Path) -> N def test_sessions_do_not_survive_app_recreation(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) owner = create_identity(tmp_path, "steady-wisely-boldly-0042") session_id = "" with TestClient(create_app()) as first_client: - registration = first_client.post("/identities/register", json={"handle": owner.handle, "did": owner.did}) - assert registration.status_code == 200 + register_and_claim_identity(first_client, tmp_path, owner.handle) session = asyncio.run( first_client.app.state.auth_sessions.create( provider="github", diff --git a/tests/server/test_identity_bootstrap.py b/tests/server/test_identity_bootstrap.py index 50eba93a..623099c5 100644 --- a/tests/server/test_identity_bootstrap.py +++ b/tests/server/test_identity_bootstrap.py @@ -3,35 +3,17 @@ import pytest from authsome.identity import create_identity -from authsome.server.identity_bootstrap import ( - HostedIdentityBootstrapService, - LocalIdentityBootstrapService, -) +from authsome.server.identity_bootstrap import IdentityBootstrapService from authsome.server.store import create_server_store from authsome.server.ui_sessions import UiSessionStore @pytest.mark.asyncio -async def test_local_bootstrap_registers_without_claim(tmp_path: Path) -> None: - identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - store = await create_server_store(home=tmp_path) - service = LocalIdentityBootstrapService(registry=store.identity_registry) - - try: - status = await service.register_identity(handle=identity.handle, did=identity.did) - - assert status.registration_status == "registered" - assert status.claim_url == "" - finally: - await store.close() - - -@pytest.mark.asyncio -async def test_hosted_bootstrap_requires_claim_until_identity_is_claimed(tmp_path: Path) -> None: +async def test_bootstrap_requires_claim_until_identity_is_claimed(tmp_path: Path) -> None: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") store = await create_server_store(home=tmp_path) ui_sessions = UiSessionStore("test-secret") - service = HostedIdentityBootstrapService( + service = IdentityBootstrapService( registry=store.identity_registry, claims=store.identity_claims, ui_sessions=ui_sessions, @@ -48,11 +30,11 @@ async def test_hosted_bootstrap_requires_claim_until_identity_is_claimed(tmp_pat @pytest.mark.asyncio -async def test_hosted_bootstrap_returns_claimed_status_after_claim(tmp_path: Path) -> None: +async def test_bootstrap_returns_claimed_status_after_claim(tmp_path: Path) -> None: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") store = await create_server_store(home=tmp_path) ui_sessions = UiSessionStore("test-secret") - service = HostedIdentityBootstrapService( + service = IdentityBootstrapService( registry=store.identity_registry, claims=store.identity_claims, ui_sessions=ui_sessions, diff --git a/tests/server/test_ownership.py b/tests/server/test_ownership.py index 2904e088..16eef6fa 100644 --- a/tests/server/test_ownership.py +++ b/tests/server/test_ownership.py @@ -6,17 +6,13 @@ import pytest from authsome.identity.principal import PrincipalRole -from authsome.server.ownership import ( - LOCAL_PRINCIPAL_EMAIL, - HostedOwnershipResolver, - LocalOwnershipResolver, -) +from authsome.server.ownership import OwnershipResolver from authsome.server.store import create_server_store from authsome.server.store.database import StoreDatabaseConfig, open_store_database @pytest.mark.asyncio -async def test_hosted_resolution_maps_identity_to_default_vault(tmp_path: Path) -> None: +async def test_resolution_maps_accepted_claim_to_principal_default_vault(tmp_path: Path) -> None: store = await create_server_store(home=tmp_path) try: principal = await store.principals.create_by_email("dev@example.com") @@ -25,7 +21,7 @@ async def test_hosted_resolution_maps_identity_to_default_vault(tmp_path: Path) await store.identity_claims.claim_identity("steady-wisely-boldly-0042", principal.principal_id) await store.identity_claims.accept_claim("steady-wisely-boldly-0042") - resolver = HostedOwnershipResolver( + resolver = OwnershipResolver( principals=store.principals, vaults=store.vaults, claims=store.identity_claims, @@ -41,25 +37,17 @@ async def test_hosted_resolution_maps_identity_to_default_vault(tmp_path: Path) @pytest.mark.asyncio -async def test_local_resolution_creates_implicit_principal_and_vault(tmp_path: Path) -> None: +async def test_resolution_rejects_unclaimed_identity(tmp_path: Path) -> None: store = await create_server_store(home=tmp_path) try: - resolver = LocalOwnershipResolver( + resolver = OwnershipResolver( principals=store.principals, vaults=store.vaults, + claims=store.identity_claims, bindings=store.principal_vault_bindings, ) - context = await resolver.resolve(identity="steady-wisely-boldly-0042") - - principal = await store.principals.get(context.principal_id) - binding = await store.principal_vault_bindings.get_default_vault(context.principal_id) - - assert principal is not None - assert principal.email == LOCAL_PRINCIPAL_EMAIL - assert principal.role == PrincipalRole.ADMIN - assert binding is not None - assert binding.vault_id == context.vault_id - assert context.role == PrincipalRole.ADMIN + with pytest.raises(ValueError): + await resolver.resolve(identity="steady-wisely-boldly-0042") finally: await store.close() diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py index fa8fa2cc..43fb97b7 100644 --- a/tests/server/test_pop_auth.py +++ b/tests/server/test_pop_auth.py @@ -2,6 +2,7 @@ import base64 from datetime import UTC, datetime, timedelta from pathlib import Path +from urllib.parse import urlparse from fastapi.testclient import TestClient @@ -34,9 +35,30 @@ def _auth_header( return {"Authorization": f"PoP {token}"} +def register_and_claim_identity( + client: TestClient, + tmp_path: Path, + handle: str = "steady-wisely-boldly-0042", + *, + email: str = "dev@example.com", +) -> None: + """Register an identity and drive the browser claim flow through to acceptance.""" + identity = create_identity(tmp_path, handle) + response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == 200 + claim_path = urlparse(response.json()["claim_url"]).path + assert client.get(claim_path).status_code == 200 + registered = client.post( + "/auth/register", + data={"email": email, "password": "password-1", "next": claim_path}, + follow_redirects=False, + ) + assert registered.status_code == 303 + assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303 + + def test_whoami_requires_pop(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: response = client.get("/whoami") @@ -47,15 +69,8 @@ def test_whoami_requires_pop(monkeypatch, tmp_path: Path) -> None: def test_whoami_accepts_valid_pop_and_scopes_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(b"\x01" * 32).decode("ascii")) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) - identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - with TestClient(create_app()) as client: - register_response = client.post( - "/identities/register", - json={"handle": identity.handle, "did": identity.did}, - ) - assert register_response.status_code == 200 + register_and_claim_identity(client, tmp_path, "steady-wisely-boldly-0042") response = client.get("/whoami", headers=_auth_header(tmp_path, "GET", "/whoami")) assert response.status_code == 200 @@ -71,11 +86,9 @@ def test_whoami_accepts_valid_pop_and_scopes_identity(monkeypatch, tmp_path: Pat def test_health_and_ready_report_encryption_details(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(b"\x02" * 32).decode("ascii")) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) - identity = create_identity(tmp_path, "steady-wisely-boldly-0042") with TestClient(create_app()) as client: - client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) + register_and_claim_identity(client, tmp_path, "steady-wisely-boldly-0042") health_response = client.get("/health") ready_response = client.get("/ready", headers=_auth_header(tmp_path, "GET", "/ready")) @@ -89,9 +102,8 @@ def test_health_and_ready_report_encryption_details(monkeypatch, tmp_path: Path) assert "Argon2id" in ready_response.json()["encryption_backend"] -def test_hosted_registration_requires_claim(monkeypatch, tmp_path: Path) -> None: +def test_registration_requires_claim(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") identity = create_identity(tmp_path, "steady-wisely-boldly-0042") with TestClient(create_app()) as client: @@ -104,7 +116,6 @@ def test_hosted_registration_requires_claim(monkeypatch, tmp_path: Path) -> None def test_whoami_rejects_wrong_path_claim(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) identity = create_identity(tmp_path, "steady-wisely-boldly-0042") with TestClient(create_app()) as client: @@ -116,7 +127,6 @@ def test_whoami_rejects_wrong_path_claim(monkeypatch, tmp_path: Path) -> None: def test_whoami_rejects_unknown_subject(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: response = client.get("/whoami", headers=_auth_header(tmp_path, "GET", "/whoami")) @@ -126,7 +136,6 @@ def test_whoami_rejects_unknown_subject(monkeypatch, tmp_path: Path) -> None: def test_whoami_rejects_registered_handle_with_wrong_issuer(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) victim = create_identity(tmp_path, "steady-wisely-boldly-0042") attacker = create_identity(tmp_path, "rapid-brightly-firmly-0007") @@ -149,7 +158,6 @@ def test_whoami_rejects_registered_handle_with_wrong_issuer(monkeypatch, tmp_pat def test_identity_registration_rejects_duplicate_handle_different_did(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) first = create_identity(tmp_path, "steady-wisely-boldly-0042") second = create_identity(tmp_path, "rapid-brightly-firmly-0007") @@ -179,11 +187,10 @@ def test_identity_registration_rejects_duplicate_did_different_handle(monkeypatc def test_ready_uses_active_identity_connections_for_warning_check(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) identity = create_identity(tmp_path, "steady-wisely-boldly-0042") with TestClient(create_app()) as client: - client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) + register_and_claim_identity(client, tmp_path, identity.handle) resolved = asyncio.run(client.app.state.ownership_resolver.resolve(identity=identity.handle)) key = build_store_key( vault=resolved.vault_id, diff --git a/tests/server/test_provider_operation_policy.py b/tests/server/test_provider_operation_policy.py index cb168c05..89b7f50d 100644 --- a/tests/server/test_provider_operation_policy.py +++ b/tests/server/test_provider_operation_policy.py @@ -34,7 +34,6 @@ def _register_admin_then_user(client: TestClient, tmp_path: Path, user_handle: s def test_hosted_revoke_is_rejected(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _register_admin_then_user(client, tmp_path, "steady-wisely-boldly-0042") @@ -49,7 +48,6 @@ def test_hosted_revoke_is_rejected(monkeypatch, tmp_path: Path) -> None: def test_hosted_remove_is_rejected(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _register_admin_then_user(client, tmp_path, "steady-wisely-boldly-0042") @@ -64,7 +62,6 @@ def test_hosted_remove_is_rejected(monkeypatch, tmp_path: Path) -> None: def test_hosted_register_provider_is_rejected(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") payload = { "definition": { "name": "custom-api", @@ -93,7 +90,6 @@ def test_hosted_register_provider_is_rejected(monkeypatch, tmp_path: Path) -> No def test_hosted_first_principal_admin_can_register_provider(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") payload = { "definition": { "name": "custom-api", diff --git a/tests/server/test_ui_dashboard.py b/tests/server/test_ui_dashboard.py index 3794a0c3..878b957d 100644 --- a/tests/server/test_ui_dashboard.py +++ b/tests/server/test_ui_dashboard.py @@ -26,12 +26,6 @@ def _auth_header(tmp_path: Path, method: str, path: str, *, handle: str) -> dict return {"Authorization": f"PoP {token}"} -def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: - identity = create_identity(tmp_path, handle) - response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) - assert response.status_code == 200 - - def _register_identity_for_claim(client: TestClient, tmp_path: Path, handle: str) -> str: identity = create_identity(tmp_path, handle) response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) @@ -39,6 +33,18 @@ def _register_identity_for_claim(client: TestClient, tmp_path: Path, handle: str return urlparse(response.json()["claim_url"]).path +def _register_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str = "dev@example.com") -> None: + """Register an identity and drive the browser claim flow, leaving the client logged in.""" + claim_path = _register_identity_for_claim(client, tmp_path, handle) + registered = client.post( + "/auth/register", + data={"email": email, "password": "password-1", "next": claim_path}, + follow_redirects=False, + ) + assert registered.status_code == 303 + assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303 + + def _seed_connection( client: TestClient, *, @@ -117,7 +123,6 @@ def _seed_provider_client( def test_overview_navigation_shows_applications_connections_and_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -132,7 +137,6 @@ def test_overview_navigation_shows_applications_connections_and_identity(monkeyp def test_applications_page_renders_provider_catalog(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -144,7 +148,6 @@ def test_applications_page_renders_provider_catalog(monkeypatch, tmp_path: Path) def test_applications_page_shows_provider_login_action(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -157,7 +160,6 @@ def test_applications_page_shows_provider_login_action(monkeypatch, tmp_path: Pa def test_identity_page_renders_informational_identity_view(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -170,7 +172,6 @@ def test_identity_page_renders_informational_identity_view(monkeypatch, tmp_path def test_hosted_identity_page_lists_all_account_claims(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: first_claim = _register_identity_for_claim(client, tmp_path, "steady-wisely-boldly-0042") @@ -196,7 +197,6 @@ def test_hosted_identity_page_lists_all_account_claims(monkeypatch, tmp_path: Pa def test_hosted_applications_redirects_to_ui_login_entry(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: response = client.get("/applications", follow_redirects=False) @@ -207,7 +207,6 @@ def test_hosted_applications_redirects_to_ui_login_entry(monkeypatch, tmp_path: def test_provider_page_shows_provider_configuration_not_connection_tokens(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -228,7 +227,6 @@ def test_provider_page_shows_provider_configuration_not_connection_tokens(monkey def test_named_connection_detail_route_exists(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -247,7 +245,6 @@ def test_named_connection_detail_route_exists(monkeypatch, tmp_path: Path) -> No def test_named_connection_detail_page_shows_oauth_tokens(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -273,7 +270,6 @@ def test_named_connection_detail_page_shows_oauth_tokens(monkeypatch, tmp_path: def test_named_connection_detail_page_shows_api_key(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -292,7 +288,6 @@ def test_named_connection_detail_page_shows_api_key(monkeypatch, tmp_path: Path) def test_provider_page_for_api_key_provider_omits_provider_setup_section(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -312,7 +307,6 @@ def test_provider_page_for_api_key_provider_omits_provider_setup_section(monkeyp def test_provider_page_lists_existing_connections_as_read_only_context(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -332,7 +326,6 @@ def test_provider_page_lists_existing_connections_as_read_only_context(monkeypat def test_connections_page_renders_connection_rows(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -353,7 +346,6 @@ def test_connections_page_renders_connection_rows(monkeypatch, tmp_path: Path) - def test_provider_login_modal_copy_is_rendered_when_default_exists(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -373,7 +365,6 @@ def test_provider_login_modal_copy_is_rendered_when_default_exists(monkeypatch, def test_connect_app_accepts_connection_name_fallback(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -401,7 +392,6 @@ def test_connect_app_accepts_connection_name_fallback(monkeypatch, tmp_path: Pat def test_provider_page_shows_configure_action_for_oauth(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -415,7 +405,6 @@ def test_provider_page_shows_configure_action_for_oauth(monkeypatch, tmp_path: P def test_provider_configure_route_opens_edit_flow_with_existing_values(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -432,7 +421,6 @@ def test_provider_configure_route_opens_edit_flow_with_existing_values(monkeypat def test_hosted_admin_provider_configure_route_opens_edit_flow(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: claim_path = _register_identity_for_claim(client, tmp_path, "steady-wisely-boldly-0042") @@ -457,7 +445,6 @@ def test_hosted_admin_provider_configure_route_opens_edit_flow(monkeypatch, tmp_ def test_provider_configure_input_page_shows_revoke_warning(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") @@ -476,7 +463,6 @@ def test_provider_configure_input_page_shows_revoke_warning(monkeypatch, tmp_pat def test_provider_config_submit_replaces_client_and_revokes_connections(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") diff --git a/tests/server/test_ui_sessions.py b/tests/server/test_ui_sessions.py index 66b77efe..6a40aff4 100644 --- a/tests/server/test_ui_sessions.py +++ b/tests/server/test_ui_sessions.py @@ -104,7 +104,6 @@ def test_build_cookie_round_trips_hosted_browser_session() -> None: def test_hosted_ui_homepage_shows_auth_tabs(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: response = client.get("/") @@ -117,7 +116,6 @@ def test_hosted_ui_homepage_shows_auth_tabs(monkeypatch, tmp_path: Path) -> None def test_hosted_claim_page_shows_auth_tabs_for_unauthenticated_users(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: identity = create_identity(tmp_path, "steady-wisely-boldly-0042") @@ -134,7 +132,6 @@ def test_hosted_claim_page_shows_auth_tabs_for_unauthenticated_users(monkeypatch def test_hosted_ui_session_returns_dashboard_url_without_browser_cookie(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _claim_identity_via_hosted_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") @@ -155,7 +152,6 @@ def test_hosted_ui_session_returns_dashboard_url_without_browser_cookie(monkeypa def test_hosted_homepage_registration_redirects_to_dashboard(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: registered = client.post( @@ -174,7 +170,6 @@ def test_hosted_homepage_registration_redirects_to_dashboard(monkeypatch, tmp_pa def test_hosted_ui_hides_server_managed_oauth_client_details(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _claim_identity_via_hosted_ui(client, tmp_path, "admin-ready-boldly-0001", "admin@example.com") @@ -194,7 +189,6 @@ def test_hosted_ui_hides_server_managed_oauth_client_details(monkeypatch, tmp_pa def test_hosted_admin_ui_shows_provider_client_details(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _claim_identity_via_hosted_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") @@ -210,7 +204,6 @@ def test_hosted_admin_ui_shows_provider_client_details(monkeypatch, tmp_path: Pa def test_hosted_ui_connect_starts_principal_scoped_session_without_pop(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: _claim_identity_via_hosted_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") @@ -227,7 +220,6 @@ def test_hosted_ui_connect_starts_principal_scoped_session_without_pop(monkeypat def test_hosted_auth_rejects_external_next_redirect(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: response = client.post( @@ -242,7 +234,6 @@ def test_hosted_auth_rejects_external_next_redirect(monkeypatch, tmp_path: Path) def test_hosted_homepage_login_error_renders_auth_page(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: client.post( @@ -265,7 +256,6 @@ def test_hosted_homepage_login_error_renders_auth_page(monkeypatch, tmp_path: Pa def test_hosted_ui_auth_input_requires_matching_browser_session(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") app = create_app() with TestClient(app) as client: From 70e5539a5924a96b7864862444957dc4e385f49e Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Fri, 29 May 2026 13:21:30 +0530 Subject: [PATCH 2/4] docs: update manual testing guide for the unified claim flow The first protected command now registers the identity and blocks on a browser email+password registration + claim (first account = admin), matching the single deployment flow. Also drop the removed `mode` field from the daemon health block. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 68b4ca3c1fc0 --- docs/internal/manual-testing.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index c0160daf..8f907cf9 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -13,7 +13,12 @@ uv run authsome --version --- -## 1. Initialization +## 1. Initialization & First-Run Claim + +There is a single flow for every deployment (see ADR 0007): the first protected +command registers your Identity and then **blocks until you claim it in the +browser** with an email + password account. The first account created on a fresh +server becomes the **admin** Principal; every later account is a regular user. ```bash # Kill daemon and start fresh (optional — skip to keep existing config) @@ -22,7 +27,20 @@ kill $(lsof -ti :7998) 2>/dev/null; rm -rf ~/.authsome uv run authsome whoami ``` -**Expected:** Home directory, registered non-default identity handle, principal ID, vault ID, DID, encryption mode, daemon URL, and connected provider count (0). +**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 register and claim this identity: + http://127.0.0.1:7998/claim/claim_ +``` + +**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. +4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step. + +**Expected (after claim):** Home directory, registered non-default identity handle, principal ID, vault ID, DID, encryption mode, daemon URL, and connected provider count (0). ```bash uv run authsome whoami --json @@ -338,7 +356,7 @@ uv run authsome list # github → not_connected uv run authsome daemon status ``` -**Expected:** JSON showing `running: true`, health checks all `ok`, PID, and log file path. The `health` block includes `version`, `mode`, `encryption_backend`. +**Expected:** JSON showing `running: true`, health checks all `ok`, PID, and log file path. The `health` block includes `version` and `encryption_backend`. ```bash uv run authsome daemon stop From 0a7c379c007afe1b01d48341b2df14f199411841 Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Fri, 29 May 2026 13:25:15 +0530 Subject: [PATCH 3/4] docs: correct manual testing guide against the current CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide had drifted well beyond the deployment-flow change: - Commands moved under the `provider` and `connections` groups (`provider list`, `provider inspect`, `provider register|remove|revoke`, `connections inspect`, `connections set-default`). Update every invocation. - Remove the documented `get`, `export`, `--show-secret`, and `--json` commands/flags — none exist. `connections inspect` is always redacted and there is no human-table mode; output is always JSON (`{"v": 1, ...}`). - Add sections for `scan`, `profile`, and `daemon restart`/`logs`. - Note admin-only operations (register/revoke) and JSON-shaped expected output throughout; fix the `daemon status` health block (no `mode`). Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: b68c1ae5d66c --- docs/internal/manual-testing.md | 266 +++++++++++++++----------------- 1 file changed, 127 insertions(+), 139 deletions(-) diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index 8f907cf9..32ee3ebc 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -1,6 +1,8 @@ # Manual Testing Guide -This guide walks through the full CLI surface. Run these after any significant change to verify that commands, flows, and output modes work end-to-end. +This guide walks through the full CLI surface. Run these after any significant change to verify that commands, flows, and output work end-to-end. + +> **Output is always JSON.** Every command prints a JSON object wrapped as `{"v": 1, ...}` to stdout. There is no `--json` flag and no human-readable table mode. `--quiet` suppresses non-essential stderr messages only; it does not change the JSON on stdout. ## Prerequisites @@ -40,25 +42,15 @@ Open this URL in your browser to register and claim this identity: 3. Confirm that the displayed identity handle is yours. 4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step. -**Expected (after claim):** Home directory, registered non-default identity handle, principal ID, vault ID, DID, encryption mode, daemon URL, and connected provider count (0). - -```bash -uv run authsome whoami --json -``` - -**Expected:** Same data as structured JSON. Key fields: `home_directory`, `profile`, `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `encryption_backend`, `connected_providers_count`. +**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `encryption_backend`, `vault_status` (`OK`), and `connected_providers_count` (`0`). ```bash uv run authsome doctor ``` -**Expected:** Exit code `0`; `OK` printed for `spec_version`, `identity`, `providers`, `connections`, `vault`, `integrity`. A `Warnings:` block may appear (e.g. "no active provider connections found") — that is normal on a fresh install. +**Expected:** Exit code `0`; JSON `{"v": 1, "status": "ready", "checks": {"spec_version": "ok", "store": "ok", "identity": "ok", "providers": "ok", "connections": "ok", "vault": "ok", "integrity": "ok"}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections (e.g. "no active provider connections found"). A non-`ready` status exits `1`. -```bash -uv run authsome doctor --json -``` - -**Expected:** `{"status": "ready", "checks": {"spec_version": "ok", ...}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections. +> **Tip:** `authsome init` performs the same register + claim flow explicitly and prints an `{"status": "initialized", ...}` payload. --- @@ -70,22 +62,18 @@ uv run authsome doctor --json uv run authsome login resend ``` -**Expected:** Terminal prints a session URL and returns immediately: -``` -Visit: http://127.0.0.1:7998/auth/sessions//input -Login process started. The connection will be updated automatically once complete. -``` +**Expected:** JSON with `status: "started"`, `provider: "resend"`, `connection: "default"`, a `session_id`, and an `auth_url` pointing at the daemon input page. The URL opens in a browser automatically. **Human action:** -1. Open the printed URL in a browser (it opens automatically if a browser is available) +1. Open the printed `auth_url` in a browser (it opens automatically if a browser is available) 2. Paste your Resend API key into the field and click **Submit** 3. The browser confirms success ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `resend` listed with status `connected`. +**Expected:** `resend` appears under `bundled` with a non-empty `connections` array whose entry shows `status: "connected"`. --- @@ -97,23 +85,19 @@ uv run authsome list uv run authsome login github ``` -**Expected:** Terminal prints a session URL and returns immediately: -``` -Visit: http://127.0.0.1:7998/auth/sessions//input -Login process started. The connection will be updated automatically once complete. -``` +**Expected:** JSON with `status: "started"`, a `session_id`, and an `auth_url`. The URL opens in a browser automatically. **Human action:** -1. Open the printed URL in a browser +1. Open the printed `auth_url` in a browser 2. Optionally enter your GitHub OAuth App Client ID and Secret (leave blank for the public flow) 3. Click **Continue** — the browser redirects to `https://github.com/login/oauth/authorize?...` 4. Click **Authorize** on GitHub; the daemon captures the callback ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `github` listed with status `connected`. +**Expected:** `github` shows a connection with `status: "connected"`. --- @@ -125,111 +109,77 @@ uv run authsome list uv run authsome login github --flow device_code ``` -**Expected:** Terminal prints a session URL and returns immediately. +**Expected:** JSON with `status: "started"`, a `session_id`, and an `auth_url`. **Human action:** -1. Open the printed URL in a browser +1. Open the printed `auth_url` in a browser 2. Leave **Client ID** blank; click **Continue** 3. The page shows a verification URL and a user code 4. Open the verification URL, enter the user code, and authorize on GitHub ```bash -uv run authsome list +uv run authsome provider list ``` -**Expected:** `github` listed with status `connected`. +**Expected:** `github` shows a connection with `status: "connected"`. --- -## 5. List - -```bash -uv run authsome list -``` - -**Expected:** Table of all providers with columns `Provider`, `Source`, `Auth`, `Connection`, `Status`, `Expires`. Connected ones show connection name and status. Header line shows total and connected counts. +## 5. Provider List ```bash -uv run authsome list --json +uv run authsome provider list ``` -**Expected:** JSON with `bundled` and `custom` arrays; each connected provider has a non-empty `connections` array. +**Expected:** JSON with `bundled` and `custom` arrays. Each provider entry has `name`, `display_name`, `auth_type`, `source`, and a `connections` array; connected providers have a non-empty `connections` array with `connection_name`, `is_default`, `status`, and (for OAuth) `scopes`/`expires_at`. --- -## 6. Get - -```bash -uv run authsome get github -``` - -**Expected:** Connection metadata; sensitive fields show `***REDACTED***`. +## 6. Connection Inspect ```bash -uv run authsome get github --show-secret +uv run authsome connections inspect github ``` -**Expected:** Actual token value printed; warning about shell history printed to stderr. +**Expected:** The connection record as JSON with sensitive fields redacted (`***REDACTED***`). There is no flag to reveal secrets via the CLI; inspect is always redacted. ```bash -uv run authsome get github --field status +uv run authsome connections inspect github --field status ``` -**Expected:** Prints `connected`. +**Expected:** `{"v": 1, "status": "connected"}`. ```bash -uv run authsome get github --field scopes +uv run authsome connections inspect github --field scopes ``` -**Expected:** Prints the scope list. +**Expected:** `{"v": 1, "scopes": [...]}` — the granted scope list. ```bash -uv run authsome get github --json +uv run authsome connections inspect github --connection default ``` -**Expected:** Connection record as JSON. +**Expected:** Same record, scoped to the named connection. An unknown `--field` returns `{"error": "FieldNotFound", ...}` and exits `1`. --- -## 7. Inspect - -```bash -uv run authsome inspect github -``` - -**Expected:** Full provider definition (URLs, flow config, scopes) as JSON; `connections` array shows active connections. - -```bash -uv run authsome inspect resend -``` - -**Expected:** Provider definition with `api_key` config block and `connections` array. - ---- - -## 8. Export - -```bash -uv run authsome export github --format env -``` - -**Expected:** `GITHUB_ACCESS_TOKEN=` printed; warning about shell history. +## 7. Provider Inspect ```bash -uv run authsome export github --format shell +uv run authsome provider inspect github ``` -**Expected:** `export GITHUB_ACCESS_TOKEN=`. +**Expected:** Full provider definition (URLs, flow config, scopes) as JSON; a `connections` array lists active connections. ```bash -uv run authsome export github --format json +uv run authsome provider inspect resend ``` -**Expected:** `{"GITHUB_ACCESS_TOKEN": ""}`. +**Expected:** Provider definition with an `api_key` config block and a `connections` array. --- -## 9. Proxy Run +## 8. Proxy Run **Prerequisite:** `github` must be connected (complete §3 first). @@ -238,49 +188,43 @@ uv run authsome export github --format json uv run authsome run --quiet curl -s https://api.github.com/user ``` -**Expected:** JSON response from GitHub containing a `login` field with your GitHub username. No proxy log noise (suppressed by `--quiet`). +**Expected:** JSON response from GitHub containing a `login` field with your GitHub username. No proxy log noise (suppressed by `--quiet`). The subprocess exit code is propagated. --- -## 10. Log +## 9. Log ```bash uv run authsome log ``` -**Expected:** Human-readable table of recent audit entries with columns `Timestamp`, `Event`, `Provider`, `Status`. Shows "No audit entries found." if empty. +**Expected:** JSON with `v`, `log_file` path, and an `entries` array of parsed audit event objects (each with `timestamp`, `event`, `provider`, `status`). Empty `entries` on a fresh install. ```bash uv run authsome log -n 5 ``` -**Expected:** Last 5 entries only (same table format). - -```bash -uv run authsome log -n 5 --json -``` - -**Expected:** JSON object with `v`, `log_file` path, and `entries` array of parsed audit event objects, each with `timestamp`, `event`, `provider`, `status`. +**Expected:** Same shape, limited to the last 5 audit entries. ```bash uv run authsome log --raw -n 10 ``` -**Expected:** Last 10 lines of the raw client debug log (loguru format). +**Expected:** JSON with `log_file` and an `entries` array containing the last 10 raw client debug log lines (loguru format). --- -## 11. Connection Management +## 10. Connection Management ```bash -uv run authsome set-default github default +uv run authsome connections set-default github default ``` -**Expected:** Confirmation that `default` is now the default connection for `github`. +**Expected:** `{"v": 1, "status": "ok", "provider": "github", "default_connection": "default"}`. --- -## 12. Custom Provider Registration +## 11. Custom Provider Registration ```bash cat > /tmp/test-provider.json << 'EOF' @@ -295,62 +239,99 @@ cat > /tmp/test-provider.json << 'EOF' } EOF -uv run authsome register /tmp/test-provider.json +uv run authsome provider register /tmp/test-provider.json ``` -**Expected:** Confirmation prompt → provider registered. No `api_url` means no reachability check. +**Expected:** `{"v": 1, "status": "registered", "provider": "test-custom", "warnings": [...]}`. No `api_url` means no reachability warning. Registering a provider requires the **admin** Principal (the first/only account in a fresh install is admin). ```bash -uv run authsome inspect test-custom +uv run authsome provider inspect test-custom ``` **Expected:** Provider definition printed as JSON; `connections` is empty. ```bash -uv run authsome list | grep test-custom +uv run authsome provider list # then look for the test-custom entry under "custom" ``` -**Expected:** Listed under `custom` source, `not_connected`. +**Expected:** `test-custom` appears in the `custom` array with an empty `connections` array. ```bash -# Register again to test --force (overwrites without prompting) -uv run authsome register /tmp/test-provider.json --force +# Register again with --force to overwrite, and --yes to skip the confirmation prompt +uv run authsome provider register /tmp/test-provider.json --force --yes ``` -**Expected:** Registers immediately, no confirmation prompt, no error. +**Expected:** Re-registers without error; `status: "registered"`. ```bash -uv run authsome remove test-custom +uv run authsome provider remove test-custom ``` -**Expected:** `Removed provider test-custom.` +**Expected:** `{"v": 1, "status": "removed", "provider": "test-custom"}`. ```bash -uv run authsome list | grep test-custom +uv run authsome provider list # confirm test-custom is gone from "custom" ``` -**Expected:** No output (provider gone). +**Expected:** No `test-custom` entry. --- -## 13. Logout and Revoke +## 12. Logout and Revoke ```bash -# Logout removes local record only +# Logout removes the local connection record only uv run authsome logout github -uv run authsome list # github → not_connected +uv run authsome provider list # github connection gone # Re-login uv run authsome login github -# Revoke removes local record and calls provider revocation endpoint -uv run authsome revoke github -uv run authsome list # github → not_connected +# Revoke deletes all stored connections/secrets for the provider (admin only) +uv run authsome provider revoke github +uv run authsome provider list # github connection gone +``` + +**Expected:** `logout` → `{"status": "logged_out", ...}`; `revoke` → `{"status": "revoked", "provider": "github"}`. A non-admin Principal is rejected with an `OperationNotAllowedError`. + +--- + +## 13. Scan (env import) + +```bash +# Report drift between env vars and stored connections (does not modify state) +uv run authsome scan +``` + +**Expected:** JSON with `connection`, `import: false`, `configured_count`, `imported_count: 0`, and a `results` array describing per-provider drift status (`env_only`, `authsome_only`, `env_and_authsome_match`, `both_missing`, etc.). `scan` rejects `--quiet`. + +```bash +# Import detected API keys from env without prompting +uv run authsome scan --import +``` + +**Expected:** `import: true` with `imported_count` reflecting newly imported keys; matching keys are reported as `skipped_unchanged`. + +--- + +## 14. Profiles + +```bash +uv run authsome profile 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. + +```bash +uv run authsome profile use work +uv run authsome whoami # profile reflects "work" (claim required on first use) +``` + +**Expected:** `profile use` → `{"status": "active", "profile": "work", ...}`. + --- -## 14. Daemon +## 15. Daemon ```bash uv run authsome daemon status @@ -360,48 +341,55 @@ uv run authsome daemon status ```bash uv run authsome daemon stop -uv run authsome daemon status +uv run authsome daemon status # running: false ``` -**Expected:** "Daemon stopped successfully"; `running: false` after stop. +**Expected:** `{"status": "stopped", "message": "..."}`; `running: false` after stop. > **Note:** If no PID record exists (e.g. after `rm -rf ~/.authsome`), `daemon stop` falls back to finding the process by port and kills it. ```bash uv run authsome daemon start -uv run authsome daemon status +uv run authsome daemon status # running: true +``` + +**Expected:** `{"status": "started", ...}`; `running: true` after start. + +```bash +uv run authsome daemon restart +uv run authsome daemon logs -n 20 ``` -**Expected:** "Daemon started successfully"; `running: true` after start. +**Expected:** `restart` → `{"status": "restarted", ...}`; `logs` → JSON with `log_file` and the last 20 daemon log lines. --- -## 15. Global Flags +## 16. Global Flags ```bash -# Quiet: suppress informational output -uv run authsome --quiet list +# Quiet: suppress non-essential stderr messages (JSON stdout unchanged) +uv run authsome --quiet provider list ``` -**Expected:** Provider table only; no "Providers: N total" banner. +**Expected:** The same provider JSON on stdout; informational/stderr chatter suppressed. ```bash -# No color: plain text output -uv run authsome --no-color list +# No color: disable ANSI colors +uv run authsome --no-color provider list ``` -**Expected:** Same table without ANSI color codes. +**Expected:** Same JSON without ANSI color codes. ```bash -# Verbose: debug logging to stderr -uv run authsome --verbose get github +# Verbose: DEBUG logging to stderr +uv run authsome --verbose connections inspect github ``` -**Expected:** DEBUG log lines on stderr in addition to normal stdout output. +**Expected:** DEBUG log lines on stderr in addition to the normal JSON stdout. --- -## 16. Error Handling +## 17. Error Handling ```bash # Non-existent provider @@ -411,7 +399,7 @@ uv run authsome login doesnotexist 2>&1; echo "exit: $?" **Expected:** `ProviderNotFoundError`, exit code `4`. ```bash -uv run authsome inspect doesnotexist 2>&1; echo "exit: $?" +uv run authsome provider inspect doesnotexist 2>&1; echo "exit: $?" ``` **Expected:** `ProviderNotFoundError`, exit code `4`. @@ -424,15 +412,15 @@ uv run authsome logout doesnotexist 2>&1; echo "exit: $?" ```bash # Missing required argument -uv run authsome get 2>&1; echo "exit: $?" +uv run authsome connections inspect 2>&1; echo "exit: $?" ``` **Expected:** Usage error, exit code `2`. ```bash -# Get on a disconnected provider +# Inspect a disconnected provider uv run authsome logout resend -uv run authsome get resend 2>&1; echo "exit: $?" +uv run authsome connections inspect resend 2>&1; echo "exit: $?" ``` **Expected:** `ConnectionNotFoundError`, exit code `3`. From 1bc504472ace6629e88a9ad06531c927ff31da26 Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Fri, 29 May 2026 13:40:25 +0530 Subject: [PATCH 4/4] feat: add admin audit dashboard --- src/authsome/server/routes/ui.py | 98 +- src/authsome/ui/static/style.css | 1083 ++++++++++++----- src/authsome/ui/templates/_layout.html | 15 +- .../ui/templates/app_detail_managed.html | 3 +- src/authsome/ui/templates/applications.html | 16 + src/authsome/ui/templates/audit.html | 71 ++ src/authsome/ui/templates/connections.html | 1 + src/authsome/ui/templates/identity.html | 13 +- src/authsome/ui/templates/overview.html | 27 +- tests/server/test_ui_dashboard.py | 49 + 10 files changed, 1015 insertions(+), 361 deletions(-) create mode 100644 src/authsome/ui/templates/audit.html diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index df0d45b3..c3592893 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -73,12 +73,13 @@ def _ui_cookie_secure(server_base_url: str) -> bool: def _ui_policy(request: Request, auth: AuthService | None = None) -> dict[str, Any]: role = auth.principal_role if auth is not None else getattr(request.state, "ui_principal_role", None) - show_provider_client_details = role == PrincipalRole.ADMIN + is_admin = role == PrincipalRole.ADMIN return { - "show_provider_client_details": show_provider_client_details, - "provider_management_label": ( - "OAuth Application" if show_provider_client_details else "OAuth application managed by Authsome" - ), + "is_admin": is_admin, + "role_label": role.value.title() if role else None, + "show_admin_sections": is_admin, + "show_provider_client_details": is_admin, + "provider_management_label": ("OAuth Application" if is_admin else "OAuth application managed by Authsome"), "show_hosted_identity": True, } @@ -213,6 +214,60 @@ def _format_relative(when: datetime | None) -> str | None: return f"{direction} {amount} {unit}{plural}" if direction == "in" else f"{amount} {unit}{plural} ago" +def _format_audit_time(value: Any) -> str: + if not value: + return "-" + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return str(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC") + + +def _humanize_audit_event(value: Any) -> str: + event = str(value or "audit_event").replace("_", " ").replace("-", " ").strip() + return event[:1].upper() + event[1:] if event else "Audit event" + + +def _audit_event_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + known = { + "event_id", + "timestamp", + "event", + "source", + "principal_id", + "identity", + "provider", + "connection", + "status", + } + rows: list[dict[str, Any]] = [] + for entry in entries: + provider = entry.get("provider") + connection = entry.get("connection") + metadata = {key: value for key, value in entry.items() if key not in known and value is not None} + target = " / ".join(str(part) for part in (provider, connection) if part) or "Authsome" + rows.append( + { + "event_id": entry.get("event_id") or "-", + "time": _format_audit_time(entry.get("timestamp")), + "event": _humanize_audit_event(entry.get("event")), + "source": entry.get("source") or "internal", + "actor": entry.get("identity") or entry.get("principal_id") or "system", + "target": target, + "status": entry.get("status") or "-", + "metadata": metadata, + } + ) + return rows + + +def _admin_required_response(title: str, message: str) -> HTMLResponse: + return HTMLResponse(pages.message_page(title, message), status_code=403) + + def _provider_status(provider_name: str, connection_summaries: list[dict[str, Any]]) -> str: """Map a connection list to a single status string for the overview cards.""" if not connection_summaries: @@ -505,6 +560,32 @@ async def identity_page( ) +@router.get("/audit", response_class=HTMLResponse) +async def audit_page( + request: Request, + auth: AuthService = Depends(require_ui_auth("/audit")), +) -> Response: + if auth.principal_role != PrincipalRole.ADMIN: + return _admin_required_response( + "Admin access required", + "Audit events are available only to administrators.", + ) + + entries = request.app.state.audit_log.list_events(limit=100) + rows = _audit_event_rows(entries) + return templates.TemplateResponse( + request, + "audit.html", + _page_context( + request, + "audit", + auth=auth, + audit_events=rows, + audit_total=len(rows), + ), + ) + + @router.get("/apps/{provider_name}", response_class=HTMLResponse) async def app_detail( provider_name: str, @@ -673,7 +754,12 @@ async def configure_provider( """Open the provider configuration flow for deployment-scoped credentials.""" provider = await auth.get_provider(provider_name) policy = _ui_policy(request, auth) - if provider.auth_type != AuthType.OAUTH2 or not policy["show_provider_client_details"]: + if not policy["show_provider_client_details"]: + return _admin_required_response( + "Admin access required", + "Provider configuration is available only to administrators.", + ) + if provider.auth_type != AuthType.OAUTH2: return _redirect(request, f"/apps/{provider_name}") session = await sessions.create( diff --git a/src/authsome/ui/static/style.css b/src/authsome/ui/static/style.css index a2fb694d..8704117f 100644 --- a/src/authsome/ui/static/style.css +++ b/src/authsome/ui/static/style.css @@ -1,475 +1,706 @@ :root { - --bg-body: #000000; - --bg-sidebar: #050505; - --bg-card: #0a0a0a; - --bg-card-hover: #141414; - --border: #27272a; - --border-subtle: #1f1f22; - --accent: #83ca16; - --accent-soft: rgba(131, 202, 22, 0.12); - --accent-strong: rgba(131, 202, 22, 0.22); - --text: #ededed; - --text-secondary: #d4d4d8; - --text-muted: #a1a1aa; - --warn: #ffb34f; - --warn-soft: rgba(255, 179, 79, 0.13); - --danger: #ff7b72; - --danger-soft: rgba(255, 123, 114, 0.12); - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 10px; - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; -} - -* { box-sizing: border-box; } - -html, body { + --bg-body: #f5f6f8; + --bg-sidebar: #ffffff; + --bg-surface: #ffffff; + --bg-subtle: #eef2f5; + --bg-muted: #e6ebf0; + --border: #d7dde5; + --border-strong: #b9c3cf; + --accent: #1f6f5b; + --accent-dark: #155041; + --accent-soft: #e5f1ed; + --blue: #2f5d88; + --blue-soft: #e7eef6; + --warn: #9a5b13; + --warn-soft: #fff3dd; + --danger: #a33a3a; + --danger-soft: #f9e4e4; + --text: #16202a; + --text-secondary: #3d4a57; + --text-muted: #6d7a86; + --shadow: 0 1px 2px rgba(22, 32, 42, 0.06); + --radius-sm: 5px; + --radius-md: 6px; + --radius-lg: 8px; + --font-sans: Aptos, "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { margin: 0; padding: 0; + min-height: 100vh; background: var(--bg-body); color: var(--text); font-family: var(--font-sans); font-size: 14px; line-height: 1.5; - min-height: 100vh; } -a { color: inherit; text-decoration: none; } - body { display: grid; - grid-template-columns: 220px 1fr; - min-height: 100vh; + grid-template-columns: 232px minmax(0, 1fr); } -button { font-family: inherit; } +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} -/* ───────── Sidebar ───────── */ .sidebar { - background: var(--bg-sidebar); - border-right: 1px solid var(--border-subtle); - padding: 18px 14px 16px; - display: flex; - flex-direction: column; - gap: 6px; position: sticky; top: 0; height: 100vh; + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px 14px 16px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); } + .brand { - font-weight: 700; + padding: 5px 10px 16px; font-size: 15px; - padding: 6px 10px 14px; - letter-spacing: -0.01em; + font-weight: 700; + letter-spacing: 0; } + .brand::after { content: "."; color: var(--accent); } -.nav-primary, .nav-secondary { + +.nav-primary, +.nav-secondary { display: flex; flex-direction: column; gap: 2px; } -.nav-spacer { flex: 1; } + +.nav-spacer { + flex: 1; +} + .nav-item { display: flex; align-items: center; gap: 10px; + min-height: 36px; padding: 8px 10px; - border-radius: var(--radius-sm); - color: var(--text-secondary); border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text-secondary); font-size: 13px; - transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; } + .nav-item:hover { - background: rgba(131, 202, 22, 0.04); - color: var(--accent); + background: var(--bg-subtle); + color: var(--text); } + .nav-item.active { - background: rgba(131, 202, 22, 0.06); - border-color: var(--border); - color: var(--accent); + background: var(--accent-soft); + border-color: #b8d9cf; + color: var(--accent-dark); + font-weight: 600; +} + +.nav-item.disabled { + opacity: 0.45; + pointer-events: none; +} + +.nav-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; } -.nav-item.disabled { opacity: 0.45; pointer-events: none; } -.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; } + .brand-foot { - font-size: 11px; - color: var(--text-muted); padding: 8px 10px 2px; + color: var(--text-muted); + font-size: 11px; } -/* ───────── Main ───────── */ .main { min-width: 0; display: flex; flex-direction: column; } + .topbar { + min-height: 57px; display: flex; - justify-content: flex-end; - padding: 14px 28px 0; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 30px; + background: rgba(255, 255, 255, 0.88); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(8px); } -.topbar-actions { + +.topbar-meta, +.topbar-actions, +.page-actions { display: flex; - gap: 6px; -} -.icon-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-secondary); - width: 30px; - height: 30px; - border-radius: var(--radius-sm); - display: inline-flex; align-items: center; - justify-content: center; - cursor: pointer; - transition: background-color 80ms ease, color 80ms ease; + gap: 8px; +} + +.topbar-actions { + justify-content: flex-end; +} + +.topbar-actions form { + margin: 0; +} + +.meta { + color: var(--text-muted); + font-size: 12.5px; } -.icon-btn:hover { background: var(--bg-card-hover); color: var(--text); } -.icon-btn svg { width: 16px; height: 16px; } .page { - padding: 22px 28px 40px; - max-width: 1240px; width: 100%; + max-width: 1220px; + padding: 26px 30px 48px; } -/* ───────── Page header ───────── */ .page-header { display: flex; - justify-content: space-between; align-items: flex-end; - gap: 12px; + justify-content: space-between; + gap: 16px; margin-bottom: 22px; } + .eyebrow { + margin-bottom: 4px; color: var(--text-muted); - font-size: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 4px; } -h1 { - font-size: 22px; + +h1, +h2 { margin: 0; - font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; +} + +h1 { + font-size: 24px; + line-height: 1.2; + font-weight: 700; } + h2 { font-size: 14px; - font-weight: 600; - margin: 0; - letter-spacing: -0.005em; + font-weight: 700; } + .subtitle { + margin: 5px 0 0; + color: var(--text-muted); + font-size: 13px; +} + +.muted, +.subtle { color: var(--text-muted); +} + +.link { + color: var(--accent-dark); font-size: 13px; - margin: 4px 0 0; + font-weight: 600; +} + +.link:hover { + text-decoration: underline; +} + +.btn, +.icon-btn { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + color: var(--text); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; } -.page-actions { display: flex; gap: 8px; } -/* ───────── Buttons ───────── */ .btn { display: inline-flex; align-items: center; + justify-content: center; gap: 6px; + min-height: 34px; padding: 7px 12px; - border-radius: var(--radius-sm); font-size: 13px; - font-weight: 500; - border: 1px solid var(--border); - background: var(--bg-card); - color: var(--text); - cursor: pointer; - transition: background-color 80ms ease, border-color 80ms ease, color 80ms ease; + font-weight: 600; + white-space: nowrap; +} + +.btn:hover, +.icon-btn:hover { + background: var(--bg-subtle); + border-color: var(--border-strong); +} + +.btn svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.btn[disabled] { + cursor: not-allowed; + opacity: 0.55; +} + +.btn-sm { + min-height: 30px; + padding: 5px 10px; + font-size: 12px; } -.btn:hover { background: var(--bg-card-hover); } -.btn svg { width: 14px; height: 14px; flex-shrink: 0; } -.btn[disabled] { opacity: 0.55; cursor: not-allowed; } -.btn-sm { padding: 5px 10px; font-size: 12px; } + .btn-primary { - background: var(--accent-soft); - color: var(--accent); + background: var(--accent); border-color: var(--accent); - font-weight: 600; + color: #ffffff; } + .btn-primary:hover { - background: var(--accent-strong); - border-color: var(--accent); + background: var(--accent-dark); + border-color: var(--accent-dark); } -.btn-secondary { background: transparent; } -.btn-secondary:hover { - background: var(--accent-soft); - border-color: var(--accent); - color: var(--accent); + +.btn-secondary { + background: var(--bg-surface); + color: var(--text-secondary); } + .btn-light { - background: #f1f4f9; - color: #000000; - border-color: #f1f4f9; - font-weight: 600; + background: var(--blue-soft); + border-color: #c8d6e5; + color: var(--blue); } -.btn-light:hover { background: #fff; border-color: #fff; } + .btn-warn-ghost { - background: transparent; + background: var(--warn-soft); + border-color: #eed09f; color: var(--warn); - border-color: var(--warn-soft); } -.btn-warn-ghost:hover { background: var(--warn-soft); } + .btn-danger-ghost { - background: transparent; + background: var(--danger-soft); + border-color: #efc3c3; color: var(--danger); - border-color: var(--danger-soft); } -.btn-danger-ghost:hover { background: var(--danger-soft); } -.btn-block { width: 100%; justify-content: center; } -/* ───────── Stat cards ───────── */ +.btn-block { + width: 100%; +} + +.icon-btn { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; +} + +.icon-btn svg { + width: 15px; + height: 15px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-secondary); + font-size: 11.5px; + font-weight: 600; + line-height: 1.35; + white-space: nowrap; +} + +.pill-accent { + background: var(--accent-soft); + border-color: #b8d9cf; + color: var(--accent-dark); +} + +.pill-neutral { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-secondary); +} + +.pill-warn { + background: var(--warn-soft); + border-color: #efd4aa; + color: var(--warn); +} + .stat-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 26px; } -.stat-card { - background: var(--bg-card); + +.stat-card, +.card, +.panel, +.notice-band, +.provider-card, +.app-card, +.empty, +.docs-card, +.connection-row { + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px 18px; + box-shadow: var(--shadow); +} + +.stat-card { + min-height: 118px; display: flex; flex-direction: column; gap: 6px; - min-height: 122px; + padding: 16px 18px; } -.stat-label { + +.stat-label, +.card-title { color: var(--text-muted); - font-size: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; text-transform: uppercase; - letter-spacing: 0.05em; } + .stat-value { - font-size: 30px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 31px; line-height: 1.1; + font-weight: 700; + letter-spacing: 0; +} + +.stat-value-sm { + font-size: 19px; + line-height: 1.25; } -.stat-value-sm { font-size: 18px; font-weight: 600; } + .stat-foot { - font-size: 12px; - color: var(--text-muted); margin-top: auto; + color: var(--text-muted); + font-size: 12px; +} + +.section { + margin-bottom: 26px; } -.stat-foot.accent { color: var(--accent); } -/* ───────── Sections ───────── */ -.section { margin-bottom: 26px; } .section-header { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; } -.link { - color: var(--accent); - font-size: 13px; - font-weight: 500; -} -.link:hover { text-decoration: underline; } -.muted { color: var(--text-muted); } -/* ───────── Provider cards (overview) ───────── */ -.provider-grid { +.provider-grid, +.app-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; } + .provider-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 16px; + min-height: 166px; display: flex; flex-direction: column; - gap: 8px; - transition: border-color 80ms ease, background-color 80ms ease; + gap: 9px; + padding: 15px; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; } -.provider-card:hover { - border-color: var(--accent-strong); - background: var(--bg-card-hover); + +.provider-card:hover, +.app-card:hover, +.connection-row:hover, +.docs-card:hover { + border-color: var(--border-strong); + box-shadow: 0 2px 7px rgba(22, 32, 42, 0.08); } + .provider-card-top { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 8px; } + .logo { width: 32px; height: 32px; - border-radius: var(--radius-sm); - background: var(--accent-soft); - color: var(--accent); + border-radius: var(--radius-md); display: inline-flex; align-items: center; justify-content: center; - font-weight: 600; + flex: 0 0 auto; + background: var(--blue-soft); + color: var(--blue); font-size: 13px; + font-weight: 700; +} + +.logo-lg { + width: 44px; + height: 44px; + font-size: 16px; +} + +.provider-card-name { + color: var(--text); + font-size: 14px; + font-weight: 700; +} + +.provider-card-desc { + min-height: 18px; + color: var(--text-muted); + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.logo-lg { width: 44px; height: 44px; font-size: 16px; } -.provider-card-name { font-weight: 600; font-size: 14px; } -.provider-card-desc { color: var(--text-muted); font-size: 12.5px; min-height: 18px; } + .provider-card-foot { - margin-top: 6px; + margin-top: auto; padding-top: 10px; - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--border); display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 10px; color: var(--text-muted); font-size: 12px; } -.icon-gear { color: var(--text-muted); display: inline-flex; } -/* ───────── Pills / badges ───────── */ -.pill { +.icon-gear { display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 999px; - font-size: 11.5px; - font-weight: 500; - border: 1px solid transparent; - line-height: 1.4; + color: var(--text-muted); } -.pill-accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-strong); } -.pill-warn { background: var(--warn-soft); color: var(--warn); border-color: rgba(255,179,79,0.25); } -/* ───────── CTA card ───────── */ -.cta-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 22px 24px; +.notice-band { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; gap: 18px; + padding: 18px 20px; +} + +.notice-title { + margin-bottom: 3px; + font-size: 14px; + font-weight: 700; +} + +.notice-body { + max-width: 680px; + margin: 0; + color: var(--text-muted); + font-size: 13px; } -.cta-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; } -.cta-body { color: var(--text-muted); font-size: 13px; max-width: 560px; margin: 0; } -/* ───────── Connections list ───────── */ .toolbar { display: flex; - gap: 12px; align-items: center; + gap: 12px; margin-bottom: 18px; } + .search { - flex: 1; + min-height: 38px; display: flex; align-items: center; gap: 8px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); + flex: 1; padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); color: var(--text-muted); + box-shadow: var(--shadow); } + .search input { - flex: 1; - background: transparent; + width: 100%; + min-width: 0; border: 0; outline: 0; + background: transparent; color: var(--text); - font-family: inherit; font-size: 13px; } + .filter-pills { display: flex; gap: 4px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); } + .filter-pill { - background: transparent; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 29px; + padding: 5px 11px; border: 0; + border-radius: var(--radius-md); + background: transparent; color: var(--text-secondary); - padding: 5px 11px; - border-radius: var(--radius-sm); - font-size: 12.5px; cursor: pointer; - display: inline-flex; - gap: 6px; - align-items: center; + font-size: 12.5px; } -.filter-pill:hover { color: var(--text); } -.filter-pill.active { background: var(--bg-body); color: var(--text); } -.filter-pill .count { color: var(--accent); font-weight: 600; font-size: 12px; } -.app-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; +.filter-pill.active { + background: var(--bg-subtle); + color: var(--text); +} + +.filter-pill .count { + color: var(--accent-dark); + font-size: 12px; + font-weight: 700; } + .app-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 12px 14px; + position: relative; + min-height: 76px; display: flex; align-items: center; gap: 12px; - position: relative; - transition: border-color 80ms ease, background-color 80ms ease; + padding: 13px 14px; } + +.app-card.is-connected { + border-color: #b8d9cf; +} + .app-card-hit { position: absolute; inset: 0; - border-radius: inherit; z-index: 1; + border-radius: inherit; +} + +.app-card-text { + min-width: 0; + flex: 1; +} + +.app-card-name { + overflow: hidden; + color: var(--text); + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-card-sub { + color: var(--text-muted); + font-size: 12px; } -.app-card:hover { background: var(--bg-card-hover); border-color: var(--accent-strong); } -.app-card.is-connected { border-color: var(--accent-strong); } -.app-card-text { flex: 1; min-width: 0; } -.app-card-name { font-weight: 500; font-size: 13.5px; } -.app-card-sub { color: var(--text-muted); font-size: 12px; } + .inline-action { - margin: 0; position: relative; z-index: 2; + margin: 0; +} + +.as-button { + cursor: pointer; } -.as-button { cursor: pointer; } .empty { - background: var(--bg-card); - border: 1px dashed var(--border); - border-radius: var(--radius-lg); - padding: 40px 24px; - text-align: center; + padding: 38px 24px; color: var(--text-muted); + text-align: center; +} + +.empty-grid { + grid-column: 1 / -1; +} + +.empty-title { + margin-bottom: 4px; + color: var(--text); + font-size: 14px; + font-weight: 700; +} + +.empty-body { + margin-bottom: 14px; + font-size: 13px; +} + +.empty .btn { + margin-top: 4px; +} + +.hidden { + display: none !important; } -.empty-grid { grid-column: 1 / -1; } -.empty-title { color: var(--text); font-weight: 600; margin-bottom: 4px; font-size: 14px; } -.empty-body { font-size: 13px; margin-bottom: 14px; } -.empty .btn { margin-top: 4px; } -.hidden { display: none !important; } -/* ───────── App detail ───────── */ .breadcrumb { + margin-bottom: 12px; color: var(--text-muted); font-size: 12.5px; - margin-bottom: 12px; } -.breadcrumb a { color: var(--text-secondary); } -.breadcrumb a:hover { color: var(--accent); } -.breadcrumb .sep { margin: 0 6px; opacity: 0.6; } + +.breadcrumb a { + color: var(--text-secondary); +} + +.breadcrumb a:hover { + color: var(--accent-dark); +} + +.breadcrumb .sep { + margin: 0 6px; + opacity: 0.6; +} .detail-header { display: flex; @@ -477,169 +708,201 @@ h2 { gap: 14px; margin-bottom: 18px; } + .detail-title { display: flex; + flex-wrap: wrap; align-items: center; gap: 10px; - font-size: 22px; - margin: 0; + font-size: 23px; } + .detail-meta { + margin-top: 3px; color: var(--text-muted); font-size: 12.5px; - margin-top: 2px; } .detail-grid { display: grid; - grid-template-columns: 1fr 240px; + grid-template-columns: minmax(0, 1fr) 260px; gap: 18px; align-items: start; } -.detail-main { display: flex; flex-direction: column; gap: 14px; } -.detail-side { display: flex; flex-direction: column; gap: 14px; position: sticky; top: 14px; } + +.detail-main, +.detail-side, +.connection-list, +.action-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.detail-side { + position: sticky; + top: 72px; +} .docs-card { display: flex; align-items: center; gap: 10px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); padding: 12px 14px; - color: var(--text); - transition: background-color 80ms ease, border-color 80ms ease; -} -.docs-card:hover { - background: var(--bg-card-hover); - border-color: var(--accent-strong); } + .docs-icon { width: 30px; height: 30px; - border-radius: var(--radius-sm); - background: var(--accent-soft); - color: var(--accent); + border-radius: var(--radius-md); display: inline-flex; align-items: center; justify-content: center; - flex-shrink: 0; + flex: 0 0 auto; + background: var(--accent-soft); + color: var(--accent-dark); +} + +.docs-icon svg { + width: 16px; + height: 16px; } -.docs-icon svg { width: 16px; height: 16px; } + .docs-title { display: block; font-size: 13px; - font-weight: 600; + font-weight: 700; } + .docs-subtitle { display: block; + margin-top: 1px; color: var(--text-muted); font-size: 12px; - margin-top: 1px; } -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-lg); +.card, +.panel { padding: 16px 18px; } + .card-title { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); margin-bottom: 10px; } -.card-body { margin: 0; color: var(--text-secondary); font-size: 13px; } -.field { margin-bottom: 12px; } -.field:last-child { margin-bottom: 0; } +.card-body { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} + +.card-warn { + background: var(--warn-soft); + border-color: #efd4aa; +} + +.field { + margin-bottom: 12px; +} + +.field:last-child { + margin-bottom: 0; +} + .field-label { + margin-bottom: 4px; color: var(--text-muted); font-size: 12px; - margin-bottom: 4px; + font-weight: 600; } + .field-row { + min-height: 34px; display: flex; align-items: center; gap: 6px; - background: var(--bg-body); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); padding: 7px 10px; - min-height: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: #fbfcfd; } + .field-row .mono { + min-width: 0; flex: 1; - color: var(--text-secondary); overflow: hidden; + color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; } -.mono { font-family: var(--font-mono); font-size: 12.5px; } + +.mono { + font-family: var(--font-mono); + font-size: 12.5px; +} + +.mask { + letter-spacing: 0; +} .scope-row { display: flex; flex-wrap: wrap; gap: 6px; } + .scope { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px 4px 10px; + border: 1px solid #b8d9cf; border-radius: 999px; background: var(--accent-soft); - color: var(--accent); + color: var(--accent-dark); font-family: var(--font-mono); font-size: 12px; - border: 1px solid var(--accent-strong); } + .scope-x { - background: transparent; + padding: 0 2px; border: 0; - color: var(--accent); - font-size: 14px; + background: transparent; + color: var(--accent-dark); cursor: pointer; - padding: 0 2px; + font-size: 14px; line-height: 1; } + .scope.scope-add { - background: transparent; - color: var(--text-muted); - border-style: dashed; border-color: var(--border); + border-style: dashed; + background: var(--bg-surface); + color: var(--text-muted); cursor: pointer; } .kv { display: grid; - grid-template-columns: max-content 1fr; + grid-template-columns: max-content minmax(0, 1fr); column-gap: 12px; row-gap: 8px; margin: 0; font-size: 12.5px; } -.kv dt { color: var(--text-muted); } -.kv dd { margin: 0; color: var(--text); } -.action-stack { - display: flex; - flex-direction: column; - gap: 8px; +.kv dt { + color: var(--text-muted); } -.action-stack form { margin: 0; } -.card-warn { - border-color: rgba(255,179,79,0.28); - background: linear-gradient(180deg, rgba(255,179,79,0.08), rgba(255,179,79,0.03)); +.kv dd { + min-width: 0; + margin: 0; + color: var(--text); } -.connection-list { - display: flex; - flex-direction: column; - gap: 10px; +.action-stack form { + margin: 0; } .connection-row { @@ -647,16 +910,7 @@ h2 { align-items: center; justify-content: space-between; gap: 12px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-md); padding: 12px 14px; - transition: border-color 80ms ease, background-color 80ms ease; -} - -.connection-row:hover { - background: var(--bg-card-hover); - border-color: var(--accent-strong); } .connection-row-main { @@ -667,51 +921,210 @@ h2 { } .connection-row-name { - font-weight: 600; - font-size: 13.5px; color: var(--text); + font-size: 13.5px; + font-weight: 700; } .connection-row-meta { + overflow: hidden; color: var(--text-muted); font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; } .connection-row-readonly { padding: 10px 12px; + box-shadow: none; } .connection-group + .connection-group { margin-top: 14px; } +.identity-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.identity-row:first-child { + padding-top: 0; +} + +.identity-row:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.identity-name { + color: var(--text); + font-weight: 700; +} + +.identity-meta { + color: var(--text-muted); + font-size: 12px; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: 13px; +} + +.data-table th, +.data-table td { + padding: 11px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.data-table th { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.data-table tbody tr:last-child td { + border-bottom: 0; +} + +.table-primary { + color: var(--text); + font-weight: 700; +} + +.table-secondary { + margin-top: 2px; + color: var(--text-muted); + font-size: 11.5px; +} + +.audit-table td:nth-child(1) { + white-space: nowrap; +} + +.audit-metadata-row td { + padding-top: 0; + background: #fbfcfd; +} + +.metadata-list { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin: 0; + color: var(--text-muted); + font-size: 12px; +} + +.metadata-list div { + display: inline-flex; + gap: 5px; +} + +.metadata-list dt { + color: var(--text-muted); + font-weight: 700; +} + +.metadata-list dd { + margin: 0; + color: var(--text-secondary); +} + .login-modal { width: min(420px, calc(100vw - 32px)); + padding: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); - background: var(--bg-card); + background: var(--bg-surface); color: var(--text); - padding: 0; + box-shadow: 0 18px 44px rgba(22, 32, 42, 0.22); } .login-modal::backdrop { - background: rgba(0, 0, 0, 0.66); + background: rgba(20, 29, 38, 0.44); } .login-modal form { display: flex; flex-direction: column; gap: 14px; - padding: 18px; margin: 0; + padding: 18px; } .login-modal-input { width: 100%; - background: var(--bg-body); + padding: 9px 10px; border: 1px solid var(--border); - border-radius: var(--radius-sm); + border-radius: var(--radius-md); + background: #fbfcfd; color: var(--text); - padding: 9px 10px; font: inherit; } + +@media (max-width: 980px) { + body { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .nav-primary, + .nav-secondary { + flex-flow: row wrap; + } + + .nav-spacer, + .brand-foot { + display: none; + } + + .topbar, + .page-header, + .notice-band { + align-items: flex-start; + flex-direction: column; + } + + .topbar-actions, + .page-actions { + flex-wrap: wrap; + } + + .page { + padding: 22px 18px 36px; + } + + .stat-grid, + .provider-grid, + .app-grid, + .detail-grid { + grid-template-columns: 1fr; + } + + .detail-side { + position: static; + } +} diff --git a/src/authsome/ui/templates/_layout.html b/src/authsome/ui/templates/_layout.html index 389c29bb..c5fac9c8 100644 --- a/src/authsome/ui/templates/_layout.html +++ b/src/authsome/ui/templates/_layout.html @@ -2,7 +2,7 @@ - + {% block title %}Authsome{% endblock %} · Authsome @@ -42,6 +42,17 @@ Identity + {% if show_admin_sections %} + + + Audit + + {% endif %}