mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
docs: update architecture language, retire Profile, add Principal/Vault/Claim terms
- Add ADR-0003: proxy unmatched requests pass through in local mode - Add docs/agents/ guidance files (domain, issue-tracker, triage-labels) - Add "Composition over inheritance" principle to AGENTS.md and CONTRIBUTING.md - Fix AGENTS.md Architecture section: remove "Identity = Profile", update AuthService constructor (vault_id/principal_id), fix storage key patterns to vault:<vault_id>:..., add five-registry model description - Update UBIQUITOUS_LANGUAGE.md: retire Profile term, add Principal, PrincipalId, VaultId, VaultHandle, IdentityClaimRecord, ClaimStatus, PrincipalVaultBindingRecord, ActiveIdentity, StorageSubstrate, StorageNamespace, SecretSource, Repository; update Relationships and Example dialogue to reflect Principal/Vault model - Add docs/refactor.md: storage composition refactor plan (Phases 1-9); mark Principal/Vault/Claim architecture as implemented, keep open storage phases Note: the feature/principal-vault-identity-design branch adds docs/adr/0003-principal-owned-vault.md — that file must be renamed to 0004-principal-owned-vault.md before that PR merges to avoid collision with the new ADR-0003 added here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Entire-Checkpoint: c7b941617a23
This commit is contained in:
@@ -56,6 +56,8 @@ These rules govern all changes to this codebase — apply them without exception
|
||||
|
||||
**Deep modules over shallow ones.** Prefer a small surface area with rich internals over many thin wrappers. More files is not more modular.
|
||||
|
||||
**Composition over inheritance.** Prefer small collaborators wired together through explicit dependencies over inheritance hierarchies. Use inheritance only when there is a real subtype relationship and composition would make the design less clear.
|
||||
|
||||
**Single responsibility and separation of concerns.** Auth authenticates. Vault stores credentials. CLI presents output. A flow must not write to storage; storage must not know about OAuth. If a function is hard to name, it's doing too many things.
|
||||
|
||||
**No premature optimization.** Don't add caching, batching, or concurrency before a measured performance problem exists. Simple and slow is fixable; complex and wrong is not.
|
||||
@@ -102,13 +104,22 @@ These rules govern all changes to this codebase — apply them without exception
|
||||
|
||||
## Architecture
|
||||
|
||||
**Identity (`src/authsome/identity/`)** manages local Ed25519 key pairs and `did:key` DIDs. `ensure_local_identity(home, active_handle)` returns the identity named in `GlobalConfig.active_identity`, or creates a new one if none exists. Key material lives at `~/.authsome/identities/<handle>.key` (mode `0600`); metadata at `~/.authsome/identities/<handle>.json`. Identity = Profile: the handle is both the cryptographic identity name and the credential namespace key.
|
||||
**Identity (`src/authsome/identity/local.py`)** manages local Ed25519 key pairs and `did:key` DIDs. Key material lives at `~/.authsome/identities/<handle>.key` (mode `0600`); metadata at `~/.authsome/identities/<handle>.json`. An Identity is a cryptographic agent — it is not a credential namespace. Credential namespacing is owned by a Vault (see below).
|
||||
|
||||
**Principal & Vault (`src/authsome/identity/principal.py`)** introduce the two concepts that own credentials. A **Principal** is a non-cryptographic logical partition (human or team) identified by an opaque `PrincipalId`. A **Vault** is a named credential store owned by exactly one Principal and identified by an opaque `VaultId`. Credentials are scoped to a vault: `vault:<vault_id>:...`. An Identity claims membership in a Principal via an `IdentityClaimRecord`; the claim must be accepted before vault access is granted.
|
||||
|
||||
**Five server-owned registries** persist in `~/.authsome/server/`:
|
||||
| Registry | File | Authoritative for |
|
||||
|----------|------|-------------------|
|
||||
| `IdentityRegistry` | `identity_registry.json` | Handle → DID mapping (PoP JWT validation) |
|
||||
| `PrincipalRegistry` | `principal_registry.json` | PrincipalId → email |
|
||||
| `VaultRegistry` | `vault_registry.json` | VaultId → VaultHandle |
|
||||
| `IdentityClaimRegistry` | `identity_claim_registry.json` | Identity → Principal claim + ClaimStatus |
|
||||
| `PrincipalVaultBindingRegistry` | `principal_vault_binding_registry.json` | Principal → default Vault binding |
|
||||
|
||||
**PoP Auth (`src/authsome/identity/proof.py`)** implements Proof-of-Possession JWT creation and validation. Every protected daemon request carries `Authorization: PoP <jwt>` signed with the local Ed25519 key. The JWT is bound to the specific HTTP method, path, and body SHA-256. The daemon validates the signature, checks the `jti` replay cache, and confirms `sub` (handle) → `iss` (DID) via the Identity Registry.
|
||||
|
||||
**Identity Registry (`src/authsome/identity/registry.py`)** is the daemon-owned authoritative handle→DID mapping, persisted at `~/.authsome/server/identity_registry.json`.
|
||||
|
||||
**AuthService (`src/authsome/auth/service.py`)** is the authentication and credential lifecycle layer. It owns OAuth flows, token refresh, login/logout/revoke. Constructed with `vault` and `identity` (the handle); all store keys are namespaced as `profile:<handle>:...`.
|
||||
**AuthService (`src/authsome/auth/service.py`)** is the authentication and credential lifecycle layer. It owns OAuth flows, token refresh, login/logout/revoke. Constructed with `(vault, identity, principal_id, vault_id)`; all credential store keys are namespaced as `vault:<vault_id>:...`. The caller (server dependency injection) resolves `vault_id` from the `PrincipalVaultBindingRegistry` before constructing `AuthService`.
|
||||
|
||||
**Flows (`src/authsome/auth/flows/`)** implement the `AuthFlow.authenticate()` interface. Each flow returns a `ConnectionRecord`.
|
||||
|
||||
@@ -119,19 +130,19 @@ These rules govern all changes to this codebase — apply them without exception
|
||||
| `dcr_pkce` | `DcrPkceFlow` | Dynamic Client Registration then PKCE |
|
||||
| `api_key` | `ApiKeyFlow` | Prompts via secure browser bridge |
|
||||
|
||||
**Provider Registry (`src/authsome/auth/service.py`)** resolves providers in this order: local `~/.authsome/providers/<name>.json` overrides bundled JSON in `src/authsome/bundled_providers/`. Bundled providers (GitHub, Google, Okta, Linear, OpenAI) are loaded via `importlib.resources`.
|
||||
**Provider Registry (`src/authsome/auth/service.py`)** resolves providers in this order: local `~/.authsome/providers/<name>.json` overrides bundled JSON in `src/authsome/auth/bundled_providers/`. Bundled providers (GitHub, Google, Okta, Linear, OpenAI) are loaded via `importlib.resources`.
|
||||
|
||||
**Vault (`src/authsome/vault/`)** is the encrypted KV store. The master key lives at `~/.authsome/server/master.key` (mode `0600`) or in the OS keyring. All credential blobs are encrypted at rest; the AuthService reads and writes plaintext through the Vault without knowing encryption details.
|
||||
|
||||
**Storage** uses a DiskStore-backed KV at `~/.authsome/server/kv_store/`. Store keys follow the pattern:
|
||||
```
|
||||
profile:<handle>:<provider>:connection:<connection_name>
|
||||
profile:<handle>:<provider>:metadata
|
||||
profile:<handle>:<provider>:state
|
||||
vault:<vault_id>:<provider>:connection:<connection_name>
|
||||
vault:<vault_id>:<provider>:metadata
|
||||
vault:<vault_id>:<provider>:state
|
||||
server:<provider>:client
|
||||
```
|
||||
|
||||
**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key field: `active_identity` (the handle of the current identity). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`).
|
||||
**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key fields: `active_identity` (the handle of the current identity), `vault_id` (the active vault resolved at `authsome init`). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`).
|
||||
|
||||
**CLI (`src/authsome/cli/main.py`)** is Click-based. All commands support `--json` for machine-readable output. `authsome init` creates the local identity, registers it with the daemon, and writes `active_identity` to config.
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ Reach for a well-maintained dependency before writing your own crypto, HTTP clie
|
||||
**Deep modules over shallow ones.**
|
||||
Prefer a module with a small surface area and rich internals over a sprawl of thin wrappers. A single `AuthClient` that handles everything cleanly beats a dozen one-method classes. More files is not more modular.
|
||||
|
||||
**Composition over inheritance.**
|
||||
Prefer small collaborators wired together through explicit dependencies over inheritance hierarchies. Use inheritance only when there is a real subtype relationship and composition would make the design less clear.
|
||||
|
||||
**Single responsibility and separation of concerns.**
|
||||
Auth authenticates. Vault stores credentials. The CLI presents output. These boundaries are not negotiable — a flow should not write to storage, and storage should not know about OAuth. If a function is hard to name, it's doing too many things.
|
||||
|
||||
|
||||
+62
-45
@@ -4,103 +4,119 @@
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **Vault** | The secure credential store. Owns the master key and DiskStore-backed KV storage. Exposes a generic encrypted key-value interface (`get`/`put`/`delete`/`list`). Encrypts full record blobs at rest. Does not know about credential types or token lifecycle. | Store, keystore, secret store |
|
||||
| **AuthLayer** | The authentication and credential lifecycle layer (`AuthService`, exported as `AuthLayer`). Owns OAuth flows, token refresh, login/logout/revoke. Receives Vault as a dependency. | Auth client, auth service |
|
||||
| **Vault** | A named credential store owned by exactly one Principal, identified by an opaque **VaultId**. Encrypts full record blobs at rest. All credential store keys are prefixed `vault:<vault_id>:...`. Does not know about OAuth or token lifecycle — that is AuthService's job. | Store, keystore, secret store, profile store |
|
||||
| **AuthLayer** | The authentication and credential lifecycle layer (`AuthService`, exported as `AuthLayer`). Owns OAuth flows, token refresh, login/logout/revoke. Receives Vault, identity handle, principal_id, and vault_id as dependencies. | Auth client, auth service |
|
||||
| **CliRuntime** | The runtime wiring container assembled once per CLI invocation (`src/authsome/cli/context.py`). Holds a `RuntimeClient` (HTTP client for daemon requests) and a `ProxyRunner`. No business logic of its own. | Client, session, app, AuthsomeContext |
|
||||
| **RuntimeClient** | The CLI's internal async HTTP client for daemon requests. Attaches PoP JWT headers to protected requests and manages identity bootstrapping. | Daemon client, HTTP client |
|
||||
| **Sensitive** | A field annotation (`Annotated[str, Sensitive()]`) marking fields that contain secret values and must be redacted before display or logging. The `redact()` utility in `utils.py` inspects this annotation to replace values with `"***REDACTED***"`. | Secret field, encrypted field |
|
||||
| **StorageSubstrate** | The concrete `AsyncKeyValue` backend where records physically live, such as `DiskStore`, `PostgreSQLStore`, or `MemoryStore`. In local mode, Client and Server may share one substrate. | Store, database, filesystem |
|
||||
| **StorageNamespace** | A prefixed collection/key space over a StorageSubstrate that defines ownership, such as `client/*`, `server/*`, or `vault/*`. Shared substrate does not imply shared namespace or shared write authority. | Folder, bucket, table |
|
||||
| **SecretSource** | A source for root key material or signing key material, resolved in priority order: environment, OS keyring, then local fallback. SecretSources hold keys; StorageSubstrates hold records. | Key store, secret store |
|
||||
| **Repository** | A domain API over a StorageNamespace. It owns key naming, model serialization, and invariants for one domain concept while depending on `AsyncKeyValue` by composition. | DAO, model manager |
|
||||
|
||||
## Identity & Authentication
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **Identity** | The cryptographic agent — an Ed25519 key pair, a `did:key` DID derived from the public key, and a human-readable Handle. Created locally by `authsome init`; the Handle/DID pair is registered with the daemon before any protected request. | User, account, actor |
|
||||
| **Handle** | The human-readable name for an Identity (e.g., `brisk-boldly-clearly-1234`). Assigned at `init` time, registered with the daemon's Identity Registry, and used as the `sub` claim in every PoP JWT. Also serves as the Profile name for credential scoping. | Username, alias, profile name |
|
||||
| **DID** | A `did:key` Ed25519 identifier derived deterministically from the Identity's public key. Encoded as `did:key:z<base58(0xed01 + raw_pubkey)>`. Appears as `iss` in PoP JWTs; the daemon verifies the signature against the public key embedded in the DID. SPIFFE URI support may be added in a future release. | Key ID, public key identifier |
|
||||
| **Identity** | The cryptographic agent — an Ed25519 key pair, a `did:key` DID derived from the public key, and a human-readable Handle. Created locally by `authsome init`; the Handle/DID pair is registered with the daemon before any protected request. Identity is not a credential namespace — credentials are scoped to a Vault, not to an Identity. | User, account, actor, profile |
|
||||
| **Handle** | The human-readable name for an Identity (e.g., `brisk-boldly-clearly-1234`). Assigned at `init` time, registered with the daemon's Identity Registry, and used as the `sub` claim in every PoP JWT. | Username, alias, profile name |
|
||||
| **DID** | A `did:key` Ed25519 identifier derived deterministically from the Identity's public key. Encoded as `did:key:z<base58(0xed01 + raw_pubkey)>`. Appears as `iss` in PoP JWTs; the daemon verifies the signature against the public key embedded in the DID. | Key ID, public key identifier |
|
||||
| **PoP JWT** | A short-lived (60 s) Proof-of-Possession JWT signed with the Identity's Ed25519 private key. Bound to a specific HTTP request via `htm` (method), `htu` (path+query), and `body_sha256`. Claims: `iss` = DID, `sub` = Handle, `jti` for replay prevention. Sent as `Authorization: PoP <token>`. | Auth token, bearer token, signed request |
|
||||
| **Identity Registry** | The daemon-owned authoritative mapping from Handle → DID, persisted at `~/.authsome/server/identity_registry.json`. A protected request is accepted only when the PoP JWT's `sub` is a registered Handle and the registry maps that Handle to the same DID as `iss`. | Identity store, key registry |
|
||||
| **Provider** | An external service (GitHub, Google, OpenAI, etc.) identified by a unique name and described by a `ProviderDefinition` | Service, integration, app |
|
||||
| **AuthType** | The authentication mechanism a provider uses — either `oauth2` or `api_key` | Auth method, auth strategy |
|
||||
| **Flow** | The specific protocol executed to obtain credentials for a provider (PKCE, Device Code, DCR+PKCE, API Key) | Auth flow, login flow, grant type |
|
||||
| **Connection** | A named, authenticated session binding a Profile to a Provider; holds credentials | Credential, token, session, auth |
|
||||
| **ConnectionStatus** | The lifecycle state of a Connection: `connected`, `expired`, `revoked`, `invalid`, `not_connected` | Status, state |
|
||||
| **Profile** | The credential namespace scoped by a Handle within the Vault. Store keys use the prefix `profile:<handle>:`. One Identity has exactly one Profile; they share the same name string (the Handle). | Environment, workspace, account |
|
||||
| **Scope** | An OAuth2 permission requested from a Provider during a Flow | Permission, role |
|
||||
| **Principal** | A non-cryptographic logical partition (human or team) that owns Vaults and performs OAuth authorization. Identified by an opaque **PrincipalId** (e.g., `principal_abc123def456`). Has no cryptographic key of its own. Every Identity registers under exactly one Principal via an **IdentityClaimRecord**. | User, account, team, workspace |
|
||||
| **PrincipalId** | An opaque stable identifier for a Principal (e.g., `principal_abc123def456`). Never the email or handle — those can change; the PrincipalId cannot. | Principal handle, principal name |
|
||||
| **VaultId** | An opaque stable identifier for a Vault (e.g., `vault_a1b2c3d4e5f6`). Used directly as the storage key segment: `vault:<vault_id>:...`. Stable across ownership and naming changes. | Vault name, vault handle |
|
||||
| **VaultHandle** | A human-readable name for a Vault (e.g., `default`). Used in UIs and CLI output; the VaultId is authoritative in storage. | Vault name, vault id |
|
||||
| **IdentityClaimRecord** | The binding from an Identity (Handle) to a Principal (PrincipalId) with a lifecycle state (ClaimStatus). Created when an Identity registers with `authsome init --email`. Vault access is gated until the claim is accepted. | Identity registration, claim |
|
||||
| **ClaimStatus** | The lifecycle state of an IdentityClaimRecord: `pending` (awaiting acceptance), `accepted` (full vault access), `rejected` (access denied). | Claim state, identity state |
|
||||
| **PrincipalVaultBindingRecord** | The server-owned record that binds a Principal to a Vault. Has `is_default` flag. A Principal may have multiple Vaults; the server resolves the default before constructing AuthService. | Principal vault, vault binding |
|
||||
| **ActiveIdentity** | Client-owned selection of which Identity signs outgoing protected requests. Stored in client config as `active_identity`. Must not be mirrored into server-owned storage. | Active profile, default profile |
|
||||
| **Provider** | An external service (GitHub, Google, OpenAI, etc.) identified by a unique name and described by a `ProviderDefinition`. | Service, integration, app |
|
||||
| **AuthType** | The authentication mechanism a provider uses — either `oauth2` or `api_key`. | Auth method, auth strategy |
|
||||
| **Flow** | The specific protocol executed to obtain credentials for a provider (PKCE, Device Code, DCR+PKCE, API Key). | Auth flow, login flow, grant type |
|
||||
| **Connection** | A named, authenticated session binding a Vault to a Provider; holds credentials. | Credential, token, session, auth |
|
||||
| **ConnectionStatus** | The lifecycle state of a Connection: `connected`, `expired`, `revoked`, `invalid`, `not_connected`. | Status, state |
|
||||
| ~~**Profile**~~ | *Retired.* Credentials were previously scoped by `profile:<handle>:...`. That namespace is replaced by `vault:<vault_id>:...`. Use **Vault** and **Principal** instead. | — |
|
||||
| **Scope** | An OAuth2 permission requested from a Provider during a Flow. | Permission, role |
|
||||
|
||||
## Provider Configuration
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **ProviderDefinition** | The complete JSON schema describing a provider's AuthType, Flow, OAuth endpoints, and export mapping | Provider config, provider spec |
|
||||
| **BundledProvider** | A ProviderDefinition shipped inside the library package | Built-in provider, default provider |
|
||||
| **ProviderRegistry** | The system that resolves a provider name to its ProviderDefinition, checking local overrides before bundled definitions | Provider loader, provider resolver |
|
||||
| **OAuthConfig** | The OAuth2-specific section of a ProviderDefinition (authorization URL, token URL, PKCE support, etc.) | OAuth settings |
|
||||
| **ApiKeyConfig** | The API-key-specific section of a ProviderDefinition (header name and prefix) | API key settings |
|
||||
| **ClientCredentials** | The OAuth2 `client_id` and `client_secret` configured for a Provider within a Profile, stored in a `ProviderClientRecord` | OAuth client, app credentials |
|
||||
| **ProviderDefinition** | The complete JSON schema describing a provider's AuthType, Flow, OAuth endpoints, and export mapping. | Provider config, provider spec |
|
||||
| **BundledProvider** | A ProviderDefinition shipped inside the library package. | Built-in provider, default provider |
|
||||
| **ProviderRegistry** | The system that resolves a provider name to its ProviderDefinition, checking local overrides before bundled definitions. | Provider loader, provider resolver |
|
||||
| **OAuthConfig** | The OAuth2-specific section of a ProviderDefinition (authorization URL, token URL, PKCE support, etc.). | OAuth settings |
|
||||
| **ApiKeyConfig** | The API-key-specific section of a ProviderDefinition (header name and prefix). | API key settings |
|
||||
| **ClientCredentials** | The OAuth2 `client_id` and `client_secret` configured for a Provider, stored in a `ProviderClientRecord`. | OAuth client, app credentials |
|
||||
|
||||
## Storage & Credentials
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **IdentityMetadata** | Client-side record for a local Identity, stored at `~/.authsome/identities/<handle>.json`. Fields: `handle`, `did`, `registered` (bool), `created_at`, `updated_at`. Written by the CLI; never read by the daemon. | Identity record, key metadata |
|
||||
| **IdentityRegistration** | Daemon-owned record in the Identity Registry binding a Handle to a DID. Fields: `handle`, `did`, `created_at`, `updated_at`. Authoritative for PoP JWT validation. | Registry entry, daemon identity |
|
||||
| **ConnectionRecord** | The persisted credential record for a Connection: plaintext tokens or API key (encrypted at rest by the Vault), scopes, expiry, and account info. `schema_version = 2`. | Token record, credential record |
|
||||
| **ProviderMetadataRecord** | Non-secret per-profile record tracking which Connections exist for a Provider and which is the default | Provider metadata |
|
||||
| **ProviderStateRecord** | Transient per-profile record tracking the last refresh attempt and any errors for a Provider | Provider state |
|
||||
| **AccountInfo** | The identity fields (id, label) returned by a Provider and stored on a ConnectionRecord | User info, identity |
|
||||
| **ClientCredentials** | The OAuth2 `client_id` and `client_secret` for a Provider, stored in a `ProviderClientRecord` at server scope (key: `server:<provider>:client`). Shared across all Profiles and users on a server instance — they represent the OAuth application registration, not the user. | User credentials, per-profile client |
|
||||
| ~~**EncryptedField**~~ | *Removed.* No longer part of the public model layer. Encryption is now handled entirely within the Vault. Tokens are stored as plaintext `str` on `ConnectionRecord` and marked with the `Sensitive` annotation for display safety. | — |
|
||||
| **IdentityMetadata** | Client-owned record for a local Identity. Fields: `handle`, `did`, `registered` (client-side cache of registration state), `created_at`, `updated_at`. The daemon never treats this as authoritative. | Identity record, key metadata |
|
||||
| **IdentityRegistration** | Server-owned record in the Identity Registry binding a Handle to a DID. Fields: `handle`, `did`, `created_at`, `updated_at`. Authoritative for PoP JWT validation. Does not contain `registered`, `active_identity`, or private key material. | Registry entry, daemon identity |
|
||||
| **ConnectionRecord** | The persisted credential record for a Connection: plaintext tokens or API key (encrypted at rest by the Vault), scopes, expiry, and account info. `schema_version = 2`. Contains `principal_id` and `vault_id` for ownership context. | Token record, credential record |
|
||||
| **ProviderMetadataRecord** | Non-secret per-vault record tracking which Connections exist for a Provider and which is the default. | Provider metadata |
|
||||
| **ProviderStateRecord** | Transient per-vault record tracking the last refresh attempt and any errors for a Provider. | Provider state |
|
||||
| **AccountInfo** | The identity fields (id, label) returned by a Provider and stored on a ConnectionRecord. | User info, identity |
|
||||
| **ClientCredentials** | The OAuth2 `client_id` and `client_secret` for a Provider, stored in a `ProviderClientRecord` at server scope (key: `server:<provider>:client`). Shared across all Vaults and Principals on a server instance — they represent the OAuth application registration, not a user. | User credentials, per-vault client |
|
||||
| ~~**EncryptedField**~~ | *Removed.* No longer part of the public model layer. Encryption is handled entirely within the Vault. Tokens are stored as plaintext `str` on `ConnectionRecord` and marked with the `Sensitive` annotation for display safety. | — |
|
||||
| ~~**CredentialStore**~~ | *Deprecated.* Internal implementation detail of the Vault. Do not use in documentation or code outside `vault/`. | — |
|
||||
| ~~**CryptoBackend**~~ | *Deprecated.* Internal implementation detail of the Vault. Do not use in documentation or code outside `vault/`. | — |
|
||||
| ~~**ProfileMetadata**~~ | *Removed.* Profile is now a credential namespace scoped by the Identity Handle; no separate metadata record is stored. | — |
|
||||
| ~~**ProfileMetadata**~~ | *Removed.* Profile is retired; no separate metadata record exists. | — |
|
||||
|
||||
## Flows
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **PKCE Flow** | Browser-based OAuth2 authorization code grant with PKCE; spins up a local callback server | OAuth flow, browser flow |
|
||||
| **Device Code Flow** | Headless OAuth2 via device authorization endpoint; polls until the user authorizes on another device | Headless flow, device flow |
|
||||
| **DCR PKCE Flow** | Dynamic Client Registration followed immediately by a PKCE Flow; used when providers require per-client registration | Dynamic registration flow |
|
||||
| **API Key Flow** | Collects an API key from the user via a Browser Bridge and stores it as a ConnectionRecord | Key flow |
|
||||
| **Browser Bridge** | A short-lived local HTTP server that presents a secure form to collect secrets (API keys) from the user interactively | Secure input, form server |
|
||||
| **PKCE Flow** | Browser-based OAuth2 authorization code grant with PKCE; spins up a local callback server. | OAuth flow, browser flow |
|
||||
| **Device Code Flow** | Headless OAuth2 via device authorization endpoint; polls until the user authorizes on another device. | Headless flow, device flow |
|
||||
| **DCR PKCE Flow** | Dynamic Client Registration followed immediately by a PKCE Flow; used when providers require per-client registration. | Dynamic registration flow |
|
||||
| **API Key Flow** | Collects an API key from the user via a Browser Bridge and stores it as a ConnectionRecord. | Key flow |
|
||||
| **Browser Bridge** | A short-lived local HTTP server that presents a secure form to collect secrets (API keys) from the user interactively. | Secure input, form server |
|
||||
|
||||
## Proxy
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
| --- | --- | --- |
|
||||
| **AuthProxy** | A local mitmproxy-based HTTP proxy that intercepts outgoing requests and injects auth headers from active Connections | Proxy, HTTP proxy |
|
||||
| **RunningProxy** | A handle to an AuthProxy running in a background thread, with a `shutdown()` method | Proxy handle, proxy instance |
|
||||
| **AuthProxy** | A local mitmproxy-based HTTP proxy that intercepts outgoing requests and injects auth headers from active Connections. In local mode, unmatched requests pass through unchanged (ADR-0003). | Proxy, HTTP proxy |
|
||||
| **RunningProxy** | A handle to an AuthProxy running in a background thread, with a `shutdown()` method. | Proxy handle, proxy instance |
|
||||
|
||||
## Relationships
|
||||
|
||||
- An **Identity** has exactly one **Profile**; both share the same name string (the **Handle**).
|
||||
- A **Profile** contains zero or more **Connections**, each scoped to one **Provider**.
|
||||
- An **Identity** is a cryptographic agent. It does not own credentials directly.
|
||||
- An **Identity** claims a **Principal** via an **IdentityClaimRecord**. The claim must be accepted (ClaimStatus = `accepted`) before vault access is granted.
|
||||
- A **Principal** owns one or more **Vaults** via **PrincipalVaultBindingRecords**. The daemon resolves the default Vault before constructing AuthService.
|
||||
- A **Vault** contains zero or more **Connections**, each scoped to one **Provider**.
|
||||
- Multiple Identities may share one Vault (they all claim the same Principal and the binding points to that Vault).
|
||||
- A **Connection** is created by exactly one **Flow**; the FlowType is determined by the **ProviderDefinition**.
|
||||
- A **ConnectionRecord** belongs to exactly one **Connection** and one **Profile**. Tokens are plaintext on the record; the **Vault** handles encryption transparently at write time.
|
||||
- A **ConnectionRecord** belongs to exactly one **Connection** and one **Vault**. Tokens are plaintext on the record; the **Vault** handles encryption transparently at write time.
|
||||
- A **ProviderRegistry** resolves a provider name by checking local `~/.authsome/providers/` overrides before **BundledProviders**.
|
||||
- The **Vault** encrypts full **ConnectionRecord** blobs using the master key it manages (file-based or OS keyring). The **AuthLayer** reads and writes records through the Vault without knowing the encryption details.
|
||||
- An **AuthProxy** draws credentials from **Connections** in the active **Profile** via the **AuthLayer** and injects them as request headers.
|
||||
- An **AuthProxy** draws credentials from **Connections** in the active **Vault** via the **AuthLayer** and injects them as request headers.
|
||||
- **CliRuntime** wires **RuntimeClient** and **ProxyRunner** together; the CLI creates one runtime per invocation via `ContextObj.initialize()`.
|
||||
- **ClientCredentials** are server-scoped, not profile-scoped. A single `ProviderClientRecord` per Provider is shared by all Profiles on a server instance. `ConnectionRecord` tokens are always profile-scoped.
|
||||
- **ClientCredentials** are server-scoped, not vault-scoped. A single `ProviderClientRecord` per Provider is shared by all Vaults and Principals on a server instance. `ConnectionRecord` tokens are always vault-scoped.
|
||||
- A **PoP JWT** is issued by the CLI for each protected daemon request. The daemon validates the signature against the **DID** embedded in `iss`, then checks the **Identity Registry** to confirm `sub` (the Handle) maps to that same DID.
|
||||
- Client and Server may share a **StorageSubstrate** in local mode, but they do not share a **StorageNamespace** or ownership authority. Remote Server storage must never contain client Identity private keys; client signing keys resolve only from client-side **SecretSources**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "If I call `authsome login github`, does it create a new **Profile**?"
|
||||
> **Dev:** "If I call `authsome login github`, does it create a new **Vault**?"
|
||||
>
|
||||
> **Domain expert:** "No — `login` adds a **Connection** to the currently active **Profile**. A **Profile** is created automatically if it doesn't exist. The `login` command runs the **Flow** specified in the GitHub **ProviderDefinition** and stores the result as a **ConnectionRecord** via the **Vault**."
|
||||
> **Domain expert:** "No — `login` adds a **Connection** to the currently active **Vault**. The active Vault is resolved at startup from the **PrincipalVaultBindingRegistry** using the Identity's **PrincipalId**. The `login` command runs the **Flow** specified in the GitHub **ProviderDefinition** and stores the result as a **ConnectionRecord** inside the Vault."
|
||||
>
|
||||
> **Dev:** "So the **Connection** is what I query later to get the access token?"
|
||||
>
|
||||
> **Domain expert:** "Exactly. When you call `get_access_token`, the **AuthLayer** looks up the **ConnectionRecord** for that **Provider** + **Profile** combination through the **Vault** (which decrypts transparently) and returns the plaintext token."
|
||||
> **Domain expert:** "Exactly. When you call `get_access_token`, the **AuthLayer** looks up the **ConnectionRecord** for that **Provider** + **VaultId** combination through the **Vault** (which decrypts transparently) and returns the plaintext token."
|
||||
>
|
||||
> **Dev:** "And if the token is expired, does it auto-refresh?"
|
||||
>
|
||||
> **Domain expert:** "Yes — the **ConnectionStatus** will show `expired`, and the **ConnectionRecord** holds the `refresh_token` so the **AuthLayer** can exchange it without re-running the **Flow**. The outcome is a new **ConnectionRecord** with updated tokens and `status = connected`."
|
||||
>
|
||||
> **Dev:** "What if I need to connect to two GitHub accounts?"
|
||||
> **Dev:** "What if I need two agents to share the same GitHub account?"
|
||||
>
|
||||
> **Domain expert:** "Use two **Profiles** — one per identity context. Each **Profile** has its own scoped keys in the **Vault**, so the two **Connections** to the `github` **Provider** are completely isolated."
|
||||
> **Domain expert:** "Put them both under the same **Principal** and point them at the same **Vault**. Each agent has its own cryptographic **Identity** but claims the same Principal; the **PrincipalVaultBindingRecord** tells the server which Vault to use, so both agents share the same **Connections**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
@@ -108,4 +124,5 @@
|
||||
- **"flow"** is used as both a `FlowType` enum value (a string like `"pkce"`) and an `AuthFlow` instance (the object that executes the protocol). Distinguish by saying **flow type** for the enum and **flow** or **flow handler** for the runtime object.
|
||||
- **"provider"** can mean a provider name string (`"github"`), a `ProviderDefinition` object, or a row in the ProviderRegistry. Prefer **Provider** (capitalized) for the concept, **ProviderDefinition** for the JSON schema, and **provider name** for the identifier string.
|
||||
- **"credential"** was used loosely in early documentation to mean both a **Connection** (the full authenticated session) and a specific secret (the token). Use **Connection** for the session and **access token** / **API key** for the individual secret values.
|
||||
- **"identity"** can mean the cryptographic agent (**Identity** — Ed25519 key pair + DID + Handle) or the `identity` parameter on `AuthService` (which is just the handle string used as a vault namespace key). Prefer **Identity** for the full concept; say **handle** when referring to the string value.
|
||||
- **"identity"** can mean the cryptographic agent (**Identity** — Ed25519 key pair + DID + Handle) or the `identity` parameter on `AuthService` (which is just the handle string carried for audit context). Prefer **Identity** for the full concept; say **handle** when referring to the string value.
|
||||
- **"vault"** can refer to the `Vault` class (the encrypted KV store implementation) or a **Vault** record (the first-class entity with a VaultId). Distinguish by context; use **VaultId** when referring to the storage namespace key.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR 0003: Unmatched Proxy Requests Pass Through In Local Mode
|
||||
|
||||
## Status
|
||||
|
||||
Accepted for the current local design. Revisit before hosted mode or strict egress control.
|
||||
|
||||
## Context
|
||||
|
||||
Authsome's proxy is bundled inside the CLI to make credential passing easier for local agents. Agents run with `HTTP_PROXY` and `HTTPS_PROXY` pointed at the local Authsome proxy. When an outbound HTTPS request matches a connected provider route, the proxy asks the Server to resolve credentials and injects the returned auth headers.
|
||||
|
||||
Not every request made by an agent is a credentialed provider request. Language runtimes, package managers, SDKs, CLIs, telemetry clients, model APIs, GitHub, documentation sites, and provider OAuth endpoints may all share the same proxy path during one agent run.
|
||||
|
||||
The proxy can respond to unmatched requests in at least three ways:
|
||||
|
||||
- pass through unchanged;
|
||||
- deny unmatched traffic;
|
||||
- support selectable modes such as permissive, warn, strict, or proposal-driven access.
|
||||
|
||||
The broader proxy design remains open. This ADR records the current local-mode behavior so it is not mistaken for an accidental default.
|
||||
|
||||
Agent Vault is an important comparison point: its service model allows unmatched hosts to pass by default, supports strict deny mode for vaults, supports explicit passthrough services, and can return proposal hints for unknown hosts. Authsome is not adopting that full model now, but the comparison clarifies the design space.
|
||||
|
||||
## Decision
|
||||
|
||||
For the current local-first design, unmatched proxy requests pass through unchanged.
|
||||
|
||||
Matched provider requests receive injected credentials. Requests that do not match a provider route are forwarded without credentials. The proxy records misses where it can classify them, but it does not block those requests by default.
|
||||
|
||||
## Rationale
|
||||
|
||||
Pass-through keeps the CLI-bundled proxy usable as an ergonomic credential broker rather than a full network policy system. It avoids breaking ordinary local agent runs where only some outbound calls need Authsome-managed credentials.
|
||||
|
||||
Deny-by-default would be a stronger egress-control posture, but it would also turn the proxy into a policy enforcement layer before Authsome has a settled policy model, host allowlist UX, proposal flow, or hosted authorization story. That would mix credential brokering with network firewall behavior too early.
|
||||
|
||||
Pass-through is therefore the least surprising local behavior: Authsome only changes requests it can confidently associate with a connected provider route.
|
||||
|
||||
## Consequences
|
||||
|
||||
The proxy is not an egress firewall in the current local design. An agent can still make outbound requests that do not use Authsome-managed credentials.
|
||||
|
||||
Credential exfiltration protection applies to credentials stored in Authsome and injected by matched routes. It does not prevent an agent from sending data it already knows to arbitrary destinations.
|
||||
|
||||
Hosted mode or stricter deployments must revisit this decision. A hosted version likely needs authorization-aware credential resolution and may need explicit unmatched-host policy such as deny, proposal, or allowlist modes.
|
||||
|
||||
Tests and docs should refer to this as an explicit local-mode decision, not as a missing feature.
|
||||
|
||||
Before this decision is reused outside local mode, Authsome also needs a header-forwarding contract, proxy credential/session-token model, netguard policy for private IP ranges and cloud metadata endpoints, DNS rebinding protection, and per-identity rate limits.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Domain Docs
|
||||
|
||||
How engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-provider-client-record-server-scope.md
|
||||
│ ├── 0002-server-registered-identities.md
|
||||
│ ├── 0003-proxy-unmatched-pass-through.md
|
||||
│ └── 0004-principal-owned-vault.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `docs/UBIQUITOUS_LANGUAGE.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0003 (example) — but worth reopening because…_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
@@ -0,0 +1,503 @@
|
||||
# Storage, Secrets, Identity, And Composition Refactor Plan
|
||||
|
||||
## Summary
|
||||
|
||||
> **Status:** The architectural foundation below (Principal, Vault, IdentityClaimRecord, PrincipalVaultBindingRecord, opaque VaultId namespace, four-registry model) is **implemented** in `src/authsome/identity/principal.py` and wired through `src/authsome/auth/service.py`. The open work is the storage composition refactor in Phases 1–9 below.
|
||||
|
||||
The remaining work:
|
||||
|
||||
- Use `py-key-value-aio`'s `AsyncKeyValue` as the only low-level storage interface.
|
||||
- Use composition over inheritance.
|
||||
- Use `DiskStore` locally and keep PostgreSQL possible later without adding remote mode now.
|
||||
- Separate **StorageSubstrate** from **StorageNamespace**.
|
||||
- Use `AesGcmEncryptionWrapper` (AES-256-GCM) for encrypted credential records.
|
||||
- Resolve master keys and identity signing keys through the same `SecretSource` chain: env, keyring, local fallback.
|
||||
- Keep client Identity private keys out of server storage permanently.
|
||||
- Keep product surface unchanged: no migration command, no remote feature launch, no new user workflow.
|
||||
- This is a new product — no data migration is needed or planned.
|
||||
|
||||
## Settled Design Rules
|
||||
|
||||
1. **Composition over inheritance**
|
||||
- Prefer composed collaborators with explicit dependencies.
|
||||
- Avoid class hierarchies for storage, identity, auth, and server orchestration.
|
||||
- Use protocols only where they describe an external seam or enable tests.
|
||||
|
||||
2. **`AsyncKeyValue` is the storage seam**
|
||||
- Do not introduce a custom `RecordStore`.
|
||||
- Use `key_value.aio.protocols.key_value.AsyncKeyValue` directly.
|
||||
- Domain code should depend on repositories, not raw stores.
|
||||
|
||||
3. **Shared substrate is not shared ownership**
|
||||
- Local mode may use one physical `DiskStore`.
|
||||
- Client and Server must use separate namespaced store wrappers.
|
||||
- Never say "shared storage" without qualifying it as "shared substrate, separate namespaces."
|
||||
|
||||
4. **Client signing keys are client-only**
|
||||
- Remote server storage must never contain client Identity private keys.
|
||||
- Server stores only public registration data: `handle`, `did`, timestamps.
|
||||
- Client owns `active_identity`, local metadata, and signing private key material.
|
||||
|
||||
5. **No product surface expansion**
|
||||
- Do not add remote mode as a user-facing feature.
|
||||
- Do not add migration support in this slice.
|
||||
- Do not add new commands unless required to preserve current behavior.
|
||||
- Use the refactor to simplify internal seams only.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Client vs Server Split
|
||||
|
||||
Every module, namespace, and repository belongs to one side. Use this as the organizing principle throughout code and docs.
|
||||
|
||||
**Client side** — what the agent or human runs locally:
|
||||
- Holds the Ed25519 identity and private signing key.
|
||||
- Signs PoP JWTs.
|
||||
- Calls the server via `RuntimeClient`.
|
||||
- CLI and UI are its interfaces.
|
||||
- Owns `client/*` namespace in storage.
|
||||
|
||||
**Server side** — the daemon process:
|
||||
- Holds the identity registry (handle → DID).
|
||||
- Holds the credential vault.
|
||||
- Runs the proxy.
|
||||
- Owns provider lifecycle.
|
||||
- Owns `server/*` and `vault/*` namespaces in storage.
|
||||
|
||||
When describing a module, repository, or method, always state which side owns it.
|
||||
|
||||
### Storage Terms
|
||||
|
||||
Use these terms consistently in docs and code comments:
|
||||
|
||||
- **StorageSubstrate** — The concrete `AsyncKeyValue` backend. Examples: `DiskStore`, `PostgreSQLStore`, `MemoryStore`.
|
||||
- **StorageNamespace** — A prefixed collection/key space over a substrate. Examples: `client/*`, `server/*`, `vault/*`.
|
||||
- **SecretSource** — A source for sensitive root/signing material. Examples: environment, OS keyring, local fallback.
|
||||
- **Repository** — A domain API over a namespaced `AsyncKeyValue` store. Owns key naming, serialization, validation, and invariants.
|
||||
|
||||
### Local Mode Wiring
|
||||
|
||||
Use one local substrate:
|
||||
|
||||
```python
|
||||
raw_store = DiskStore(directory=home / "server" / "kv_store")
|
||||
```
|
||||
|
||||
Compose namespaced stores:
|
||||
|
||||
```python
|
||||
client_store = PrefixCollectionsWrapper(raw_store, prefix="client")
|
||||
server_store = PrefixCollectionsWrapper(raw_store, prefix="server")
|
||||
vault_base_store = PrefixCollectionsWrapper(raw_store, prefix="vault")
|
||||
```
|
||||
|
||||
Create encrypted credential store:
|
||||
|
||||
```python
|
||||
vault_store = AesGcmEncryptionWrapper(vault_base_store, key=master_key)
|
||||
```
|
||||
|
||||
Repositories receive stores:
|
||||
|
||||
```python
|
||||
identity_repo = IdentityRepository(client_store, secret_resolver)
|
||||
identity_registry = IdentityRepository(server_store, secret_resolver=None)
|
||||
credential_repo = CredentialRepository(vault_store)
|
||||
provider_repo = ProviderRepository(server_store)
|
||||
```
|
||||
|
||||
### Future Remote Wiring
|
||||
|
||||
Do not implement as product behavior now, but ensure the composition can support this later:
|
||||
|
||||
```python
|
||||
client_raw = DiskStore(directory=home / "client" / "kv_store")
|
||||
server_raw = PostgreSQLStore(url=database_url, table_name="authsome_kv")
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```python
|
||||
client_store = PrefixCollectionsWrapper(client_raw, prefix="client")
|
||||
server_store = PrefixCollectionsWrapper(server_raw, prefix="server")
|
||||
vault_store = AesGcmEncryptionWrapper(
|
||||
PrefixCollectionsWrapper(server_raw, prefix="vault"),
|
||||
key=master_key,
|
||||
)
|
||||
```
|
||||
|
||||
Client signing keys still resolve only from client-side `SecretSource` instances.
|
||||
|
||||
## Concrete Implementation Phases
|
||||
|
||||
### Phase 1: Storage Composition Foundation
|
||||
|
||||
Add a storage factory module.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Build raw local `DiskStore`.
|
||||
- Build prefixed client/server/vault stores.
|
||||
- Build AES-256-GCM encrypted vault store (`AesGcmEncryptionWrapper`).
|
||||
- Return a small composition object, for example:
|
||||
|
||||
```python
|
||||
class StorageGraph(BaseModel):
|
||||
client: AsyncKeyValue
|
||||
server: AsyncKeyValue
|
||||
vault: AsyncKeyValue
|
||||
```
|
||||
|
||||
Use a dataclass if easier. Do not create an inheritance hierarchy.
|
||||
|
||||
Keep current local paths initially unless there is a direct reason to move them.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Existing code can still use old wrappers while new repositories are introduced.
|
||||
- No product behavior changes.
|
||||
- Update `docs/UBIQUITOUS_LANGUAGE.md` with StorageSubstrate, StorageNamespace, and client/server split terms.
|
||||
|
||||
### Phase 2: Secret Source Chain
|
||||
|
||||
Introduce a shared secret source model.
|
||||
|
||||
Required sources:
|
||||
|
||||
- `EnvSecretSource` — reads environment variables; never writes; highest priority.
|
||||
- `KeyringSecretSource` — reads/writes OS keyring; second priority; fails gracefully if keyring is unavailable.
|
||||
- `LocalSecretSource` — last-resort local fallback; uses local files with `0600` protection.
|
||||
|
||||
Introduce `SecretResolver`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
```python
|
||||
resolver.get_or_create(name, factory)
|
||||
```
|
||||
|
||||
Resolution:
|
||||
|
||||
1. Read env.
|
||||
2. Read keyring.
|
||||
3. Read local fallback.
|
||||
4. Generate with factory.
|
||||
5. Persist to first available writable source, preferring keyring then local fallback.
|
||||
6. Never persist generated material to env.
|
||||
|
||||
Secret names:
|
||||
|
||||
```text
|
||||
vault:master
|
||||
identity:<handle>:private_key
|
||||
ui:session_signing_key
|
||||
```
|
||||
|
||||
Generate a 32-byte random key for AES-256-GCM. Store it under the secret name `vault:master`.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Master key and identity private keys use one resolution model.
|
||||
- Current config-backed encryption mode becomes implementation detail or transitional compatibility.
|
||||
|
||||
### Phase 3: IdentityRepository
|
||||
|
||||
Create one `IdentityRepository` implementation.
|
||||
|
||||
It composes:
|
||||
|
||||
```python
|
||||
IdentityRepository(store: AsyncKeyValue, secrets: SecretResolver | None)
|
||||
```
|
||||
|
||||
It should support both client and server identity records through methods, not subclasses.
|
||||
|
||||
Client-owned methods:
|
||||
|
||||
```python
|
||||
create_local_identity(handle: str | None = None) -> IdentityMetadata
|
||||
load_local_identity(handle: str) -> IdentityMetadata | None
|
||||
get_active_identity() -> str | None
|
||||
set_active_identity(handle: str) -> None
|
||||
mark_registered(handle: str) -> IdentityMetadata
|
||||
load_private_key(handle: str) -> Ed25519PrivateKey
|
||||
```
|
||||
|
||||
Server-owned methods:
|
||||
|
||||
```python
|
||||
register_public_identity(handle: str, did: str) -> IdentityRegistration
|
||||
resolve_registration(handle: str) -> IdentityRegistration | None
|
||||
list_registered_handles() -> list[str]
|
||||
```
|
||||
|
||||
Enforce by wiring:
|
||||
|
||||
- Client repository receives a client namespace and secret resolver.
|
||||
- Server repository receives a server namespace and no private-key resolver.
|
||||
- Server code never calls private-key methods.
|
||||
|
||||
Do not create `ClientIdentityRepository` and `ServerIdentityRepository` unless later behavior truly diverges.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Current `identity/local.py`, `identity/registry.py` behavior is preserved behind one repository.
|
||||
- Server private-key access is structurally absent.
|
||||
|
||||
### Phase 4: CredentialRepository
|
||||
|
||||
Create `CredentialRepository` over encrypted `AsyncKeyValue`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Save/load/delete `ConnectionRecord`.
|
||||
- Save/load/delete `ProviderMetadataRecord`.
|
||||
- Save/load/delete `ProviderStateRecord`.
|
||||
- Save/load/delete server-scoped `ProviderClientRecord`.
|
||||
- List provider groups for a vault.
|
||||
|
||||
It owns all credential key construction.
|
||||
|
||||
Preserve current semantics:
|
||||
|
||||
- Connection records are vault-scoped (key prefix `vault:<vault_id>:...`).
|
||||
- Provider metadata/state are vault-scoped.
|
||||
- Provider client records are server-scoped.
|
||||
- `default` remains only a connection name, not an identity/vault fallback.
|
||||
|
||||
Expected result:
|
||||
|
||||
- `AuthService` stops manually building storage keys.
|
||||
- Store key parsing/building leaves general utility code or becomes repository-private.
|
||||
|
||||
### Phase 5: ProviderRepository
|
||||
|
||||
Move custom provider persistence out of `AuthService`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Load bundled provider definitions.
|
||||
- Load custom provider definitions from server namespace.
|
||||
- Resolve custom overrides before bundled providers.
|
||||
- Save/delete custom providers where policy allows.
|
||||
|
||||
Keep behavior unchanged.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Auth can ask for providers through a collaborator.
|
||||
- Provider persistence stops being mixed into credential lifecycle.
|
||||
|
||||
### Phase 6: Slim AuthService
|
||||
|
||||
Refactor `AuthService` to receive collaborators by composition:
|
||||
|
||||
```python
|
||||
AuthService(
|
||||
identity: str,
|
||||
principal_id: str,
|
||||
vault_id: str,
|
||||
providers: ProviderRepository,
|
||||
credentials: CredentialRepository,
|
||||
deployment_mode: str,
|
||||
)
|
||||
```
|
||||
|
||||
Flow selection stays internal to `AuthService`. Flows are instantiated directly by string key (`"pkce"`, `"device_code"`, etc.) — no `FlowRegistry` type needed.
|
||||
|
||||
Keep AuthService focused on:
|
||||
|
||||
- Start login.
|
||||
- Resume login.
|
||||
- Refresh.
|
||||
- Logout/revoke.
|
||||
- API key validation.
|
||||
- Return provider auth headers.
|
||||
|
||||
Move out of AuthService:
|
||||
|
||||
- Store key construction.
|
||||
- Provider loading/persistence.
|
||||
- Proxy route catalog construction.
|
||||
- Server identity/policy checks.
|
||||
|
||||
Expected result:
|
||||
|
||||
- AuthService gets smaller and deeper.
|
||||
- Tests can focus on auth behavior without constructing full storage state manually.
|
||||
|
||||
### Phase 7: Server Composition Root
|
||||
|
||||
Create explicit server state composition.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```python
|
||||
class ServerState:
|
||||
storage: StorageGraph
|
||||
identities: IdentityRepository
|
||||
providers: ProviderRepository
|
||||
credentials: CredentialRepository
|
||||
```
|
||||
|
||||
Auth is constructed on demand per-identity via a plain factory function — no `AuthServiceFactory` type:
|
||||
|
||||
```python
|
||||
def make_auth_service(identity: str, state: ServerState) -> AuthService: ...
|
||||
```
|
||||
|
||||
PoP JWT verification calls `verify_pop_jwt(...)` from `identity/proof.py` directly — no `ProofVerifier` wrapper.
|
||||
|
||||
Server dependencies should build from `ServerState`, not scattered constructors.
|
||||
|
||||
Health/ready endpoints should use direct dependencies:
|
||||
|
||||
- storage health
|
||||
- secret resolver health
|
||||
- vault encrypted roundtrip
|
||||
- registry availability
|
||||
|
||||
Expected result:
|
||||
|
||||
- Server is the only module that wires cross-domain workflows.
|
||||
- The `identity="server"` placeholder disappears.
|
||||
|
||||
### Phase 8: Proxy Server Authority
|
||||
|
||||
Move proxy route/credential decisions to Server-facing services.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Proxy asks Server for route catalog or per-request resolution.
|
||||
- Server decides whether target matches a connected provider.
|
||||
- Server returns:
|
||||
- pass-through
|
||||
- inject headers
|
||||
- future deny/proposal, not implemented now
|
||||
|
||||
Keep ADR-0003 behavior: unmatched local requests pass through.
|
||||
|
||||
Expected result:
|
||||
|
||||
- Proxy does not rebuild auth/provider logic locally.
|
||||
- Proxy remains a transport/interface module.
|
||||
|
||||
### Phase 9: Documentation Final Review
|
||||
|
||||
Each preceding phase ships with its own doc update (see individual Expected Result sections). Phase 9 is a final consistency pass, not a catch-up.
|
||||
|
||||
Required at final review:
|
||||
|
||||
- Confirm `docs/UBIQUITOUS_LANGUAGE.md` reflects all terms: StorageSubstrate, StorageNamespace, SecretSource, SecretResolver, IdentityRepository, IdentityRegistration, CredentialRepository, ProviderRepository, client/server split.
|
||||
- Confirm the AES-256-GCM encryption model is documented; remove any remaining Fernet references.
|
||||
- Confirm filesystem layout docs reflect `client/*` and `server/*` namespace separation.
|
||||
- Confirm `default` is documented as a connection name only, never a vault or identity fallback.
|
||||
|
||||
## Required Tests
|
||||
|
||||
### Storage Tests
|
||||
|
||||
- Local storage graph uses one raw substrate with separate prefixed namespaces.
|
||||
- Writing client namespace data does not appear in server namespace reads.
|
||||
- Writing server namespace data does not appear in client namespace reads.
|
||||
- Vault namespace stores AES-256-GCM encrypted payloads in the raw substrate.
|
||||
- Repository callers never need raw collection names.
|
||||
|
||||
### Secret Source Tests
|
||||
|
||||
- Env source wins over keyring and local.
|
||||
- Keyring wins over local when env is absent.
|
||||
- Local fallback is used when env/keyring are unavailable.
|
||||
- Generated secrets are not written to env.
|
||||
- Generated secrets persist to keyring when available.
|
||||
- Generated secrets persist to local fallback when keyring is unavailable.
|
||||
- Missing secret plus failing writable sources returns a clear error.
|
||||
|
||||
### Identity Tests
|
||||
|
||||
- Creating a local identity stores metadata in client namespace.
|
||||
- Creating a local identity stores private key through client-side secret resolver.
|
||||
- Loading private key works from env.
|
||||
- Loading private key works from keyring.
|
||||
- Loading private key works from local fallback.
|
||||
- Server registration stores only public registration fields.
|
||||
- Server namespace never contains private key material.
|
||||
- `registered` remains client-side cache only.
|
||||
- `active_identity` remains client-side only.
|
||||
- Protected request validation uses server registry, not client metadata.
|
||||
|
||||
### Credential Tests
|
||||
|
||||
- Connection record save/load roundtrips through encrypted vault store.
|
||||
- Provider metadata/state remain vault-scoped.
|
||||
- Provider client credentials remain server-scoped.
|
||||
- Revoke behavior preserves current multi-vault semantics.
|
||||
|
||||
### Auth Tests
|
||||
|
||||
- AuthService works with repositories instead of direct Vault key construction.
|
||||
- Login session begin/resume still persists connection records.
|
||||
- Refresh updates connection record and provider state.
|
||||
- API key flow persists connection record.
|
||||
- Auth does not import identity key-loading helpers.
|
||||
- Auth does not import storage factory code.
|
||||
|
||||
### Server Tests
|
||||
|
||||
- Server validates PoP JWT and registry binding before constructing vault-scoped auth.
|
||||
- Unknown handle is rejected.
|
||||
- DID mismatch is rejected.
|
||||
- App startup builds ServerState once.
|
||||
- `/ready` no longer depends on `AuthService(identity="server")`.
|
||||
- UI local identity resolution remains equivalent.
|
||||
- Hosted/local policy behavior remains unchanged.
|
||||
|
||||
### Proxy Tests
|
||||
|
||||
- Proxy uses server-provided route/credential data.
|
||||
- Matched provider request injects returned headers.
|
||||
- Unmatched local-mode request passes through (ADR-0003).
|
||||
- Auth endpoint bypass remains intact.
|
||||
- Ambiguous route behavior remains safe and tested.
|
||||
- Proxy does not load credentials directly from repositories.
|
||||
|
||||
### Verification Commands
|
||||
|
||||
Run before claiming done:
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
uv run ruff check src/ tests/
|
||||
uv run ty check src/
|
||||
uv run pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not implement a hosted product mode.
|
||||
- Do not implement PostgreSQL deployment documentation.
|
||||
- Do not implement migration from old file layout.
|
||||
- Do not encrypt logical storage keys in this slice.
|
||||
- Do not add network policy modes to proxy.
|
||||
- Do not add approval/proposal flows.
|
||||
- Do not create inheritance hierarchies for stores or repositories.
|
||||
- Do not implement an `AuditRepository` or store audit events in KV. Audit is an append-only structured log (`audit.log`), not a queryable store.
|
||||
|
||||
## Open Concerns To Recheck Before Implementation
|
||||
|
||||
- Whether current tests rely on the raw encrypted value format of the old `VaultCrypto` — these will need updating when `AesGcmEncryptionWrapper` lands.
|
||||
- Whether `py-key-value-aio` key enumeration is sufficient, or if repositories need explicit index records.
|
||||
- Whether local fallback secrets should live in the same raw `DiskStore` or remain flat files for bootstrap simplicity.
|
||||
- Whether moving `config.json` to the client namespace should happen in this slice or later.
|
||||
|
||||
## Recommended First Slice
|
||||
|
||||
Implement the smallest valuable slice first:
|
||||
|
||||
1. Add `SecretSource` chain.
|
||||
2. Add storage graph factory using `AsyncKeyValue`, prefixes, and `AesGcmEncryptionWrapper`.
|
||||
3. Move identity client metadata/registration behind `IdentityRepository`.
|
||||
4. Keep existing `AuthService` mostly unchanged until identity/storage wiring is stable.
|
||||
5. Add tests proving client/server namespace separation and private key non-leakage.
|
||||
|
||||
This first slice improves the architecture without expanding product behavior or forcing the whole AuthService refactor at once.
|
||||
Reference in New Issue
Block a user