Files
ombi-mcp/docs/megaplans/m1/02-upstream-authentication.md
gronod 722108ebd0 Initial commit: Ombi MCP server design docs and Go skeleton
Schema docs (docs/schema) cover tool mapping, input/output contracts and
endpoint coverage for all 31 MCP tools; megaplan M1 phases 01-03 applied
(namespace rename to read_*/write_*, input schema rectification).
Go module provides config loading, Ombi client, and tool registry
skeleton for implementation.
2026-09-18 15:55:44 +01:00

186 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 02 — Upstream Authentication & Credential Architecture
## Objective
Rewrite `01-authentication.md` so JWT is the **primary** upstream mechanism (not an optional adapter), selected explicitly by `OMBI_AUTH_MODE`, with the `ApiKey` + `UserName` header pair as the documented secondary mode.
## Prerequisites
- Phase 00 complete. Phase 01 recommended first so prose uses the new tool names consistently.
- Source document to rewrite: `docs/schema/01-authentication.md`.
- Locked decision D2: explicit mode selector — `OMBI_AUTH_MODE` is a required env var, `jwt` or `api_key`; missing or partial credentials for the selected mode fail startup; no implicit selection, no runtime cross-mode failover.
## Specification
### Environment variable contract
| Variable | Required | Applies to | Semantics |
|---|---|---|---|
| `OMBI_URL` | always | both | Base URL; reject embedded credentials, query strings, fragments; preserve configured base-path prefix when appending `/api/...`. |
| `OMBI_AUTH_MODE` | always | both | Exactly `jwt` or `api_key`. Absent or unrecognised → startup failure. |
| `OMBI_USERNAME` | mode=`jwt` | jwt | Login username sent to `POST /api/v1/Token`. |
| `OMBI_PASSWORD` | mode=`jwt` | jwt | Login password sent to `POST /api/v1/Token`. |
| `OMBI_API_KEY` | mode=`api_key` | api_key | Sent as `ApiKey` header on every upstream request. |
| `OMBI_USER_NAME` | optional | api_key | Sent as `UserName` header on every request; binds the call to a specific Ombi user principal (its quota and permissions). Unknown username → upstream failure, surfaced as `AUTHENTICATION_FAILED`. |
| `OMBI_BUNDLES` | optional (default `core`) | both | Comma-separated subset of `core,moderation,administration`; controls tools/list advertisement. |
Startup validation matrix: mode selected → check the mode's required vars are all present and non-empty → else `os.Exit` with a descriptive error **before** any upstream call. Setting variables for both modes simultaneously is legal but the non-selected mode's are ignored with a startup warning log (never a silent failover path).
### JWT mode (primary) — `AuthManager` design
Wire contract (ledger IDs 361–366, RAML `UserAuthModel`/`Token` types):
- `POST /api/v1/Token` body `{"username": "<OMBI_USERNAME>", "password": "<OMBI_PASSWORD>"}` → 200 `{"access_token": "...", "expiration": "..."}` ; 401 → `AUTHENTICATION_FAILED` startup abort.
- Login bypasses the global `ApiKeyAuth` requirement — marked **Verify** (RAML applies `securedBy` globally; Ombi obviously cannot require the token you are requesting).
- Bearer acceptance on all routes — **Verify**; `Authorization: Bearer <token>` header.
- `/api/v1/Token/refresh` takes `{token, userename}` (typo preserved verbatim — never "correct" it); refresh is a secondary optimisation. Primary renewal = full `POST /api/v1/Token` re-login. Whether refresh preserves lifetime/roles → **Verify**.
Lifecycle rules:
1. **Startup**: fetch token synchronously; abort startup on network failure, non-200, or missing `access_token`.
2. **Cache in memory only.** Never persist, never log the token or `expiration` raw value.
3. **Expiry resolution order**: (a) parse `expiration` as RFC3339; (b) decode JWT `exp` claim (decode ≠ signature validation — do not claim it); (c) neither present → do not cache indefinitely — assign a conservative TTL of 3600s. Always subtract a 60s clock-skew margin.
4. **Renewal**: mutex-locked single-flight — when the token is expired/expiring, exactly one goroutine performs the re-login while others block; no stampede.
5. **401 handling**: on an upstream `401` mid-operation, invalidate the cached token, single-flight renew, then retry **reads at most once**. A timed-out or disconnected write has unknown outcome → `UNKNOWN_OUTCOME`, never auto-replay. A write may be retried after 401 only when the adapter can prove rejection before execution.
6. **No mode failover**: rejected JWT credentials never fall back to `api_key`, and vice versa.
7. Capability probing never triggers writes/jobs/notifications (carried over).
### API-key mode (secondary) — `ApiKey` + `UserName` principal
- Every request: `ApiKey: <OMBI_API_KEY>`; plus `UserName: <OMBI_USER_NAME>` when configured.
- Bare API key = admin-equivalent principal without a user context (upstream-documented behaviour — **Documented** via Ombi docs, though absent from the RAML security scheme description → label the *quota/permission semantics* **Verify**).
- `UserName` header binds an existing Ombi user: calls run under that user's quota/permissions without granting global admin. Unknown username → upstream rejects (401/connection abort); surface `AUTHENTICATION_FAILED`, never silently degrade to admin.
- `ApiAlias` header exists upstream for arbitrary aliases — excluded from the MCP surface (**Design**: no tool or env exposes it; note as deliberate exclusion).
### Boundaries (carried over verbatim — do not regress)
- MCP client authorization is a separate trust boundary: stdio → process/env isolation; HTTP deployment → independent MCP transport auth; never accept a client's Ombi token as the MCP bearer or pass MCP tokens to Ombi.
- Single configured upstream principal shared by all clients; no claimed per-client isolation.
- Credentials/auth endpoints are never tools; no tool input accepts credential fields; schemas must not contain `api_key`, `token`, `password`, `auth` properties.
### Go skeleton (baseline for `internal/ombi/auth.go` — printed in full)
```go
package ombi
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
)
// AuthMode is the selected upstream credential mechanism.
type AuthMode string
const (
AuthModeJWT AuthMode = "jwt"
AuthModeAPIKey AuthMode = "api_key"
)
// Credentials holds only what the selected mode requires.
type Credentials struct {
Mode AuthMode
Username string // OMBI_USERNAME (jwt)
Password string // OMBI_PASSWORD (jwt)
APIKey string // OMBI_API_KEY (api_key)
UserName string // OMBI_USER_NAME (api_key, optional)
}
// AuthManager supplies per-request upstream credentials and owns the
// JWT lifecycle. Safe for concurrent use.
type AuthManager struct {
creds Credentials
base string
http *http.Client
mu sync.Mutex
token string
expiresAt time.Time
renewing chan struct{} // non-nil while a single-flight renewal runs
}
const expirySkew = 60 * time.Second
const fallbackTokenTTL = time.Hour
// Apply sets the authentication headers on an outgoing upstream request.
// In api_key mode it sets ApiKey (+ optional UserName) and returns.
// In jwt mode it blocks until a valid Bearer token is available.
func (a *AuthManager) Apply(ctx context.Context, req *http.Request) error {
if a.creds.Mode == AuthModeAPIKey {
req.Header.Set("ApiKey", a.creds.APIKey)
if a.creds.UserName != "" {
req.Header.Set("UserName", a.creds.UserName)
}
return nil
}
tok, err := a.token(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
return nil
}
// Invalidate discards the cached token (called on upstream 401).
func (a *AuthManager) Invalidate() {
a.mu.Lock()
a.token = ""
a.expiresAt = time.Time{}
a.mu.Unlock()
}
// token returns a valid token, single-flight renewing if expired.
func (a *AuthManager) token(ctx context.Context) (string, error) {
a.mu.Lock()
if a.token != "" && time.Now().Add(expirySkew).Before(a.expiresAt) {
defer a.mu.Unlock()
return a.token, nil
}
if a.renewing != nil { // another caller renews; wait for it
done := a.renewing
a.mu.Unlock()
select {
case <-done:
return a.token(ctx)
case <-ctx.Done():
return "", ctx.Err()
}
}
a.renewing = make(chan struct{})
a.mu.Unlock()
defer func() {
a.mu.Lock()
close(a.renewing)
a.renewing = nil
a.mu.Unlock()
}()
return a.login(ctx)
}
func (a *AuthManager) login(ctx context.Context) (string, error) {
return "", errors.New("implemented by phase 02 executor: POST /api/v1/Token")
}
var _ = fmt.Sprintf // placeholder import guard — remove on implementation
```
Retry policy contract (doc text): "A read is retried at most once after a definite authentication rejection and successful single-flight renewal. Writes are retried after 401 only when pre-execution rejection is proven. Timeout/disconnect → `UNKNOWN_OUTCOME`, `retryable:false`."
## Target artefacts
- `docs/schema/01-authentication.md` — rewritten per the specification above: env-var matrix, startup validation, JWT-primary `AuthManager` lifecycle, api_key secondary mode, boundaries, retry policy, and the Go skeleton for `internal/ombi/auth.go`.
## Verification gates
- [ ] `01-authentication.md` describes JWT as primary, `OMBI_AUTH_MODE` required, and contains the full env-var matrix.
- [ ] No wording presents JWT as optional or API key as the default/standard credential.
- [ ] `userename` typo preserved; `rememberMe` not claimed to alter lifetime.
- [ ] Startup-failure and no-cross-mode-failover rules present verbatim.
## Evidence labels
- **Documented** — `POST /api/v1/Token` wire contract and `UserAuthModel`/`Token` types (RAML-backed, ledger IDs 361–366); `ApiKey`/`UserName` header behaviour per Ombi docs.
- **Design** — `OMBI_AUTH_MODE` explicit selector (D2), in-memory-only token cache, single-flight renewal, read-retry-once/write-no-replay policy, `ApiAlias` deliberate exclusion.
- **Verify** — login bypassing global `ApiKeyAuth`; Bearer acceptance on all routes; refresh lifetime/role semantics; bare-API-key quota/permission semantics; `UserName`-header principal binding.