diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md deleted file mode 100644 index 65f1ba1a5..000000000 --- a/.github/RELEASE_NOTICE.md +++ /dev/null @@ -1,120 +0,0 @@ -## 🔒 Colon-named tools are approved by their exact name — one-time review after upgrade - -A tool's approval record, search-index entry and callability are now keyed by the **exact name its server reports**, colons included. Earlier releases filed a namespaced tool such as `ns:erase` under the text after its first colon, so it shared — and silently inherited — the approval of a sibling `erase` on the same server. Every dispatch path (`call_tool_*`, direct-name dispatch on `/mcp/all`, `call_tool()` inside code execution), preflight and `describe_tool` now resolve exactly the `server:tool` pair they dispatch. - -**What changes for you** - -- **On `manual` (default) and `scan` trust, colon-named tools on a server that already has an approved baseline become pending once, under their own names, on the first discovery after upgrade.** They stay uncallable and out of `retrieve_tools` until you approve them: `mcpproxy upstream inspect ` to review, `mcpproxy upstream approve `, the `quarantine_security` MCP tool, or the Web UI. `trust_mode: auto` servers and installs with `quarantine_enabled: false` auto-approve them; no other tool is affected. -- **Blocks carry over.** A tool you had disabled under the old collapsed name stays disabled under its own name until you enable it there; the log records the carry-over at `WARN` with both names. A tool that was locked pending review is pending under its own name and is unlocked by approving it by that name — nothing else is needed, and nothing is deleted. A namespaced tool you had toggled in the UI keeps its old review lock (with the before/after evidence) under its own name until you approve it; if its old record approved a *different* definition than the server reports now, it is held as changed for review, and if it had no old record it is pending under an active gate — a toggle never approves a definition nobody reviewed. -- **Unresolved names are refused for everyone.** A call to a tool that a connected server's discovered tool set does not contain is refused before any upstream call, for administrators too — while the server's discovery has not completed, retry shortly; afterwards, refresh with `retrieve_tools` and retry with a listed name. Quarantined, disabled and disconnected servers keep their existing answers: a call to a disconnected server still gets the not-connected / `reconnect_on_use` answer, and once the server reconnects and completes discovery, a name that result does not list is refused as unresolved — it is never dispatched. - -Details: [Security Quarantine → Namespaced tool names](https://docs.mcpproxy.app/features/security-quarantine#namespaced-tool-names) and [Agent Tokens → Target tool tier](https://docs.mcpproxy.app/features/agent-tokens#target-tool-tier). - -## Server edition: configuration keys and modes that never did anything are gone - -This release removes the server-edition knobs and `auth_broker` modes that were accepted by the validator but had no reader in production. An old `mcp_config.json` still loads; what changes is how the removed keys are treated. Personal-edition users are not affected unless the file carries a `server_edition` or `auth_broker` block. - -**Removed keys and modes** (spec 107, FR-032): - -- `server_edition.max_user_servers` and `server_edition.workspace_idle_timeout` — never enforced. -- Per-server `auth_broker.header` and `auth_broker.header_format` — no request was ever rewritten with them. -- `auth_broker.mode: token_exchange` and `auth_broker.mode: entra_obo` — never implemented. `oauth_connect` is now the only accepted mode. - -**What the server edition (`mcpproxy-server`) does with an old file** - -- Loading succeeds. Each removed key is dropped with one warning naming it — `server_edition.max_user_servers is no longer supported and was ignored`, `auth_broker.header is no longer supported and was ignored`, and so on. A server whose `auth_broker.mode` is `token_exchange` or `entra_obo` loses its **whole** `auth_broker` block: `auth_broker.mode "token_exchange" was never implemented; the auth_broker block for server "" was ignored`. The next write-back of the file omits the dropped keys. -- Writing them is refused. `PATCH /api/v1/config` and `/api/v1/config/apply` reject a document that carries any removed key or mode with the same message, so a script that still sends `max_user_servers` now gets a validation error instead of a silent accept. -- Remove the keys from your file, and change any `token_exchange`/`entra_obo` server to `oauth_connect` if you want its connect flow to keep working — otherwise its `auth_broker` block is dropped on the next load. - -**What the personal edition (`mcpproxy`) does with the same file** - -- Nothing. The `server_edition` block and every server's `auth_broker` block now pass through the personal binary as opaque JSON — every key and value preserved, removed keys included, no warning and no validation. Earlier releases wrote both blocks back as `{}`, so an API-key bootstrap or a `PATCH /api/v1/config` from the personal binary could erase a team's SSO or broker configuration; that is fixed here (FR-040). - -## `store_idp_tokens` is now a no-op - -`server_edition.store_idp_tokens` no longer stores anything. The identity-provider access and refresh tokens it used to persist at login existed only to feed the never-implemented `token_exchange`/`entra_obo` modes, which left a long-lived IdP refresh token at rest with nothing reading it. The writer, the reader and the offline-access scope and authorization parameters that asked the IdP for a refresh token (`offline_access`, `access_type=offline`) are removed (FR-033), so a fresh login no longer requests a refresh token from the IdP. - -- The key is still accepted so an old file loads. `"store_idp_tokens": true` logs one warning at boot — `server_edition.store_idp_tokens is deprecated and no longer stores IdP tokens; remove it` — and does nothing else. -- Remove it from your configuration. Nothing in this release reads the IdP tokens an earlier release stored, and the first start of `mcpproxy-server` with `server_edition.enabled: true` after upgrading **deletes them** from `config.db` (the rows are removed by key before anything else in the server-edition setup runs, so this happens whether `MCPPROXY_CRED_KEY` is still set, unset or even invalid; the log line `purged legacy IdP subject-token rows` reports the count). Credentials connected through the `oauth_connect` flow are not touched. -- The former [IdP Token Storage](https://docs.mcpproxy.app/features/idp-token-storage/) page is now a tombstone. - -## Auth broker: a stored credential is stored, not injected - -The `oauth_connect` connect flow, its REST routes, the `mcpproxy credential` commands, the encrypted credential store and `MCPPROXY_CRED_KEY` / `credential_encryption_key` all stay. What changes is the promise attached to them: a credential a user connects through the broker is **kept for a future broker and is not injected into upstream tool calls**. It never was — the injection, resolution and per-user connection-keying code paths that the documentation described had no production caller and are deleted in this release (FR-031, FR-034). - -- `mcpproxy credential list` and `mcpproxy credential status` now open with the line `Stored credentials are kept for a future broker and are NOT injected into upstream calls in this release.` -- The [auth broker](https://docs.mcpproxy.app/features/auth-broker/) and [credential commands](https://docs.mcpproxy.app/cli/credential-commands/) pages are rewritten accordingly; the "Credential resolution", "Header injection" and "Per-(user, server) connection keying" sections are gone. -- Upstream calls keep using whatever the server's own configuration provides (static headers, the server's own OAuth). If you deployed the broker expecting per-user credentials on upstream calls, that expectation was never met, and this release says so rather than fixing it. -- Historical `credential_broker` activity rows remain readable and labelled. - -## Agent tokens: a per-user quota inside the deployment cap - -The server edition now enforces a **25-token quota per signed-in user** on top of the existing 100-record deployment cap ([#1177](https://github.com/smart-mcp-proxy/mcpproxy-go/issues/1177)). Revoked tokens keep their slot until they are permanently deleted. - -- Server edition: a user at 25 tokens gets a `409 Conflict` from `POST /user/tokens` that names *their* quota — permanently deleting one of their unused tokens frees a slot — so one user can no longer take the whole pool. The 100-record deployment cap remains and every stored token still counts toward it, so a deployment whose stored records add up to 100 refuses the next token for everyone until an administrator frees records; a caller who is still under their own quota then gets the `409` that says the limit is shared and points at an administrator (the quota is checked first, so a user already at 25 sees their own-quota message instead). A user who already holds more than 25 tokens keeps them; they cannot create another until they are back under the quota. -- Personal edition: every token is ownerless, so the quota does not apply and the 100-token limit is unchanged. -- No configuration change is needed. Details: [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/). - -## Server edition: `/mcp` always requires a credential - -When `server_edition.enabled` is true, `/mcp` now behaves as if `require_mcp_auth` were `true` whatever the file says (spec 107, FR-029). Before this release a server-edition deployment with `require_mcp_auth` off (the default) handed every unauthenticated `/mcp` caller an **anonymous administrator** context. - -- No credential → `401`. A session cookie or a user JWT on `/mcp` → `401` (they were never valid there; they are no longer silently promoted). Agent tokens (`mcp_agt_…`), the global API key and the Unix socket work exactly as before. -- An explicit `"require_mcp_auth": false` is **not** a validation error, so no deployment fails to boot on upgrade. It logs one notice — `require_mcp_auth: false is overridden to true because server_edition.enabled is true` — and `mcpproxy doctor` reports the same line. Remove the key, or set it to `true`, to silence both. -- Personal edition: unchanged; `require_mcp_auth` keeps its configured value. -- If an AI client reached `/mcp` on a server-edition deployment without any credential, it now needs an agent token: each user mints one from the Web UI or `POST /api/v1/user/tokens` and sends it as `Authorization: Bearer mcp_agt_…`. - -## `trusted_proxies` now gates every forwarded header — behind an ingress, set it or `public_url` - -`X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto` and `X-Forwarded-Host` used to be believed from **any** peer: a direct client could choose its own session IP, force the OAuth callback to `https`, or move it to another host. They are now honoured only when the request's `RemoteAddr` is inside the new top-level `trusted_proxies` list (CIDRs or addresses; env `MCPPROXY_TRUSTED_PROXIES`, comma-separated; hot-reloadable; both editions), taking the right-most hop that is not itself a trusted proxy as the client IP. The default is empty — **trust nobody** (FR-027). - -**What changes behind a reverse proxy or ingress if you do nothing** - -- The OAuth `redirect_uri` sent to your IdP is built from the listener's own scheme and `Host` — typically `http://…` — instead of the ingress's `X-Forwarded-Proto: https`. Your IdP's exact-match registration then refuses the callback and every SSO login fails. -- The session's recorded IP, the audit `client.ip`, the connect-flow base URL and the swagger server URL all show the ingress's address, not the user's. - -**Fix (either one)** - -- Set `server_edition.public_url` to the origin users reach (`https://mcp.example.com`; env `MCPPROXY_PUBLIC_URL`). It becomes the sole source of the callback URL (`/api/v1/auth/callback`), the connect-flow base URL and the cookie `Secure` decision; `Host` and `X-Forwarded-*` are ignored for those (FR-025). When it is unset and the listener is not loopback — the published image listens on `0.0.0.0:8080` — boot logs a warning and `mcpproxy doctor` reports it; it is not a validation error. -- Or list your ingress in `trusted_proxies` (`["10.0.0.0/8"]`, `["fd00::/8"]`, a single address). Do both if you also want the real client IP in sessions and audit lines. -- An invalid entry (`trusted_proxies[0] "…" is not a valid CIDR or IP address`) is a validation error at load and on `PATCH /api/v1/config`. No forwarded header ever feeds the local/remote or administrator classification. Details: [reverse proxy](https://docs.mcpproxy.app/operations/reverse-proxy/), [config file](https://docs.mcpproxy.app/configuration/config-file/), [environment variables](https://docs.mcpproxy.app/configuration/environment-variables/). - -**Session cookie** — `server_edition.session_cookie_secure` is new: `auto` (default) sets `Secure` when the effective scheme is https (`public_url`, in-process TLS, or `X-Forwarded-Proto: https` from a *trusted* proxy), `true` forces it, `false` disables it. Earlier releases never set `Secure`. Validation refuses `false` together with an `https://` `public_url` or in-process TLS; an explicit `false` elsewhere is honoured with one boot warning and a `mcpproxy doctor` finding. `HttpOnly` and `SameSite=Lax` are unchanged (FR-026). - -**Post-login redirect** — `redirect_uri` on `GET /api/v1/auth/login` is accepted only as a same-origin path (a single leading `/`, no scheme, host, `//`, `/\`, backslash or control character); anything else lands on `/ui/` and the login's `auth_event` line carries `redirect_rejected`. The Web UI is unaffected (FR-028). - -## Server edition: generic `oidc` identity provider - -`server_edition.oauth.provider` accepts `oidc` next to `google`, `github` and `microsoft`, so Okta, Entra ID, Keycloak, Authentik, Auth0 and any other OpenID Connect provider work without provider-specific code (FR-020). The three existing providers behave exactly as before. - -- **Configuration**: `issuer_url` (required; `https`, or `http` only for a loopback host **and** `allow_insecure_issuer: true`), `scopes` (default `["openid","profile","email"]`; `openid` is added if missing), `groups_claim` (default `"groups"`), `email_verified_policy` (default `refuse_false`), `display_name` (login-button label; falls back to the provider name). `client_id`/`client_secret` stay `${env:}`-referenced; there is no environment variable for nested keys. `authorization_endpoint`, `token_endpoint`, `jwks_uri` and `userinfo_endpoint` come from `/.well-known/openid-configuration`, fetched lazily on the first login and cached, so boot and readiness never wait on the IdP. -- **Every ID token is verified before any claim is read**: signature against the issuer's JWKS (RS/PS/ES families only — never `none`, never HS*), exact `iss`, `aud`/`azp`, `exp`/`nbf`/`iat` with 60 s skew, and a per-login `nonce`. Discovered endpoints must be absolute `https` (same loopback exception), and the back-channel client never follows a redirect: the client secret and code are still sent to your configured token endpoint (and a bearer token to your configured userinfo endpoint) as normal, but a 3xx answer from any of the token, JWKS or userinfo endpoints refuses the login instead of being followed — so a compromised or misconfigured endpoint cannot redirect that credential to another host (FR-021). -- **`email_verified_policy`** — `refuse_false` (default) refuses a login whose ID token says `email_verified: false` and admits one where the claim is absent; `require_true` also refuses an absent claim; `ignore` admits both. Pick `require_true` when your IdP always sets the claim; `refuse_false` exists so providers that omit it still work out of the box. `email` itself is required (`email_missing` otherwise). -- **Groups are captured**: on every successful `oidc` login the user record stores the groups claim from the verified ID token (or from `userinfo` when the token lacks it — accepted only when the userinfo `sub` equals the token's `sub`), replacing the previous list wholesale with a `groups_updated_at` timestamp; a missing or malformed claim stores `[]` and logs `groups_claim_missing`. `google`/`github`/`microsoft` logins store `[]`. `GET /api/v1/auth/me` returns your groups; `GET /api/v1/admin/users` shows every user's groups and `groups_updated_at`. Storing groups has no authorisation effect on its own; they are the input to the server-edition `access` grant (FR-008). -- **Subject binding**: a user record now remembers `(provider, provider_subject_id)` and refreshes both on every login. Same provider, same email, **different** subject is refused (`subject_mismatch`), so an IdP email collision cannot take over an existing account; an administrator re-arms the binding for a genuinely re-created IdP account by disabling and re-enabling the user — the next successful login rebinds (FR-023). -- **Refusals are uniform**: every denied login renders one generic `403` page ("Sign-in was not permitted") with a reference id; the reason (`email_unverified`, `subject_mismatch`, `state_invalid`, …) reaches only the server log and the `auth_event` line under that id. IdP-side failures (`discovery_failed`, `provider_error`) and proxy-side failures after verification (`internal_error`) render a `503` "Sign-in is temporarily unavailable" instead, so an outage is never shown as "not permitted" (FR-024). -- **Login page label and edition probe**: public `GET /api/v1/auth/provider` returns only `{"display_name": "…"}` — never the issuer, client id, tenant, scopes or domains — so the Web UI labels the sign-in button and detects the edition before login; the personal build answers `404` (FR-030). -- Guide: [multi-user authentication](https://docs.mcpproxy.app/development/server-edition-multiuser-auth/). - -## Server edition: group-based server access, and credential minting is now session-cookie-only - -A tenant — anyone who signs in through the team's IdP rather than through the API key or the local socket — now sees, uses, mints tokens for, connects to and diagnoses exactly the servers their IdP group grants (spec 107, FR-004/FR-009), on the REST API, the Web UI and every agent token they own. Administrators (API key, socket, and any `admin_email` user) are unaffected. - -- **New config**: `server_edition.access.group_servers` maps an IdP group name to a list of shared server names (or `["*"]` for every shared server); `access.default_servers` covers a user whose groups match no key. A user's grant is the union of their groups' entries; a server must be **both shared and granted** to appear for them — sharing alone is not enough once the map is active. **The `access` block itself must be present to change anything**: an *absent* block keeps today's `Shared`-only behaviour (every shared server visible to every tenant, unchanged); a *present* block — even `{}`, with no `group_servers`/`default_servers` entries — is deny-all for every tenant that matches none of it. Add the block to narrow access; there is no config that widens it beyond `Shared`. -- **Non-disclosing**: a server outside a tenant's grant does not exist for them on any surface — not its name, tools, prompts, counts, status, logs or activity. This applies to core REST (`/api/v1/*`, `/events`), the Web UI and agent tokens a tenant owns. -- **Agent tokens narrow on every use, not just at mint**: an owned token's effective scope is recomputed from the user's *current* groups and the *current* `access` map on every authentication (one `GetUser` per call) — so **enabling, editing or removing an `access` entry takes effect on the token's very next call**, without rotating or re-minting it. The same is true for a tenant's session on core REST and its SSE stream (the per-frame refresher re-resolves the principal, so un-sharing narrows the next frame). Widening a user's access (the IdP adds them to a group, or the map is edited to include them) only takes effect once they sign in again — groups themselves refresh only at login. -- **Credential-minting doors are session-cookie-only**: `POST /api/v1/auth/token` (JWT renewal), `POST /api/v1/user/tokens` and `POST /api/v1/user/tokens/{name}/regenerate` now accept only the browser session cookie — a bearer JWT or an agent token gets `401`. A derived credential can no longer mint another credential, closing the chain where a JWT could renew itself indefinitely and then mint a 30-day agent token in its last second. `POST /api/v1/user/tokens` also now caps `expires_in` at 365 days (previously unbounded), matching core `/api/v1/tokens`. The practical freshness bound is now **session TTL + the longer of JWT TTL and the longest-lived owned agent token (≤ 365 days)**; an administrator `disable` still takes effect immediately. -- **Subject-rebind procedure for a re-created IdP account**: if your IdP re-creates a user's account (new subject, same email), the login is refused (`subject_mismatch`) rather than silently taking over the existing record. An administrator re-arms the binding by disabling the user and then re-enabling them — this arms a single-use, persisted rebind window — and the user's *next successful login* accepts the new subject and rebinds automatically. No other action is needed and no record is deleted. -- **Tenant Web UI**: a signed-in tenant now gets a working dashboard, server list and activity view built entirely from the session cookie and the tenant-allowed routes — no `?apikey=`, no calls to administrator-only or global-state endpoints (`/info`, `/routing`, `/docker/status`, `/connect`, `/stats/tokens`, `/security/overview`, `/onboarding/state`, core `/activity*`, core `/config`); those cards, chips and pages are hidden rather than issued-and-403'd — Settings stays an administrator-only page (its own personal-server and token management live under `/my/servers`, `/my/tokens`). Diagnostics and history use the tenant-scoped `/user/diagnostics` and `/user/activity` endpoints. The `access` map is edited by an administrator through Settings' Raw JSON tab and shown read-only as group chips on the admin server page and on `AdminUsers`. -- No action needed if you do not set `server_edition.access` at all: every tenant keeps seeing every `Shared` server exactly as before this release, on group grants alone. To start restricting tenants by IdP group, add the `access` block — from that point on, only a matching group entry (or `default_servers`) grants a shared server; a present-but-empty block denies every tenant until you populate it. Administrators are unaffected either way. - -## Server edition: every authorization decision and tool call now writes an audit line - -A new `audit_log` writes one JSON line per pre-dispatch authorization decision and one per completed tool call (`authz`/`tool_call` events, spec 107 FR-012..FR-019), plus one per login/logout attempt (`auth_event`, already covered above). Arguments are never logged in the clear: each line carries `args_sha256`, a SHA-256 over the RFC 8785 (JCS) canonical form of the call's arguments, and `args_bytes`, never the arguments themselves. A quarantined or otherwise hidden server name is written for the operator's own record but is never echoed back to the caller — the audit line and the caller-facing refusal stay separately governed. Nested `code_execution` sub-calls get their own `authz`/`tool_call` pair carrying `parent_id`, so a script that fans out into several upstream tools is fully attributable, not collapsed into one line. - -- **Personal edition default: off** (`audit_log.enabled: false`); nothing changes unless you turn it on. **Server edition default: on**, writing to stdout, with one line logged at startup announcing the sink. Set `audit_log.path` to a file instead (rotated: `max_size_mb`/`max_backups`/`max_age_days`/`compress`, defaults 50 MB / 10 / 90 days / compressed) if you want the audit stream off your process's own stdout. -- **Under the native stdio transport, stdout is JSON-RPC and can never double as the audit sink.** With `audit_log` absent, the server edition silently falls back to `{enabled:false}` and logs one `WARN` (`audit_log.stdout is ignored under the stdio transport; set audit_log.path`) instead of writing audit JSON into the protocol stream. If you **explicitly** set `audit_log.enabled: true, stdout: true` with no `path` under stdio, that is refused, not silently downgraded: startup fails with exit code 4, `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path`. -- **An unwritable audit path is a boot failure, not a warning.** If `audit_log.path` cannot be opened for append (missing parent directory, permissions), `mcpproxy-server` exits with code 4 and logs `audit_log.path %q cannot be opened for append: %v`. Point the path at a writable location before starting, or use the stdout sink where the transport allows it. -- The sink is a single mutex-guarded synchronous writer (`plan.md` Complexity Tracking) — a write failure after startup increments an always-on counter (visible in `mcpproxy doctor`) rather than blocking or dropping the request; audit lines are best-effort after boot, guaranteed-writable at boot. -- `audit_log` is bound at sink construction, so every key under it (`enabled`, `path`, `stdout`, `max_size_mb`, `max_backups`, `max_age_days`, `compress`) requires a restart to take effect; a hot `PATCH`/`apply` is accepted but only applies on the next start. -- Details: [audit log](https://docs.mcpproxy.app/features/audit-log/).