fix(pier): end only the revoked token's or client's MCP sessions and log the client address (B-WR-04, B-IN-04)
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a narrowed revoked event closes only matching sessions and stateless servers - cabana fires the admin token revoke with the token row id - towel.WithClientIP/ClientIP; pier puts the address (surf applies http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
This commit is contained in:
@@ -60,7 +60,7 @@ Use cases:
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, "The payload must be a string." for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
|
||||
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A listener's error is logged and never fails the save.
|
||||
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. A listener's error is logged and never fails the save.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
|
||||
@@ -246,7 +246,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.APITokenPrefix` | The `g15_` prefix of a personal API token plaintext. |
|
||||
| `cabana.ResolveBackend` | Authenticates a request outside the admin middleware from the Authorization Bearer header only (admin JWT or `g15_` token, never the admin cookie), with an optional scope cover check. A request whose context carries a `bouncer.Dispatch` marker for the `backend` guard (an in-process dispatch; no HTTP request can set one) is answered from the marker: its principal must be a backend one, a frontend marker is `cabana.ErrUnauthenticated`, and a scoped principal must still cover every cover code. |
|
||||
| `cabana.ErrUnauthenticated` / `cabana.ErrTokenScope` | Sentinel errors from `cabana.ResolveBackend`. |
|
||||
| `cabana.ResolveBackendToken` | Resolves a `g15_` token outside any request (same hash lookup, usability window, admin load and `last_used_at` stamp as the guard) without the request-method scope filter; returns the principal, the token row and the stored scope names. For an in-process dispatcher that opens a session with a personal token. |
|
||||
| `cabana.ResolveBackendToken` | Resolves a `g15_` token outside any request (same hash lookup, usability window, admin load and `last_used_at` stamp as the guard) without the request-method scope filter; returns the principal, the token row and the stored scope names. For an in-process dispatcher that opens a session with a personal token. Each attempt is logged as `admin.auth` with the client address the caller put on the context (`towel.ClientIP`). |
|
||||
| `cabana.ScopeLimitFor` | Filters stored scope names by one HTTP method into the `bouncer.ScopeLimit` the guard would set for a request with that method, with the known and method-allowed counts. |
|
||||
| `cabana.BackendPrincipal` | Loads an admin principal by id through the backend users provider (grants, superuser flag, backend kind set); nil, nil for a missing or deactivated admin. |
|
||||
| `cabana.AdminAPIToken` / `cabana.AdminAPITokenScope` / `cabana.AdminAPITokenCreated` / `cabana.AdminAPITokenRequest` | JSON shapes of the account token API. |
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
)
|
||||
|
||||
@@ -163,8 +162,9 @@ func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
return
|
||||
}
|
||||
// A revoked token ends what it opened elsewhere (an MCP session).
|
||||
fireSubjectChanged(r.Context(), s.app, pact.MCPSubjectRevoked, principal.ID)
|
||||
// A revoked token ends the MCP sessions opened with this token only; the
|
||||
// admin's sessions through another token or an OAuth grant stay open.
|
||||
fireTokenRevoked(r.Context(), s.app, principal.ID, row.ID)
|
||||
WriteData(w, http.StatusOK, mapAdminAPIToken(r.Context(), s.translator(), s.reg, row, time.Now().UTC()), nil)
|
||||
}
|
||||
|
||||
@@ -305,4 +305,4 @@ func resolveScopePhrase(ctx context.Context, tr *phrasebook.Translator, key, fal
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +42,24 @@ func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adm
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fireTokenRevoked fires one pact.MCPSubjectRevoked for an administrator's
|
||||
// personal token, narrowed to that token with TokenID: the MCP endpoint ends
|
||||
// only the sessions opened with this token, not the admin's other sessions
|
||||
// (another token, or an OAuth grant acting as the linked admin). It fires
|
||||
// after the commit like fireSubjectChanged and never fails the revoke.
|
||||
func fireTokenRevoked(ctx context.Context, app *backpack.App, adminID, tokenID uint) {
|
||||
if app == nil || app.Events == nil || adminID == 0 || tokenID == 0 {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
tx, _ := TxFromContext(ctx)
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: adminID, Kind: pact.MCPSubjectRevoked, TokenID: tokenID}
|
||||
if err := app.Events.Fire(ctx, event); err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", pact.MCPSubjectRevoked, "admin_id", adminID, "token_id", tokenID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
// resolverTokenPath is the path logTokenAuthAt records for a token check that
|
||||
@@ -23,32 +24,35 @@ const resolverTokenPath = "mcp"
|
||||
// admin and an application without cabana activated all answer
|
||||
// ErrUnauthenticated; the audit line is logged under path "mcp".
|
||||
func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *BackendAPIToken, []string, error) {
|
||||
// The caller's client address, when it put one on the context (pier
|
||||
// does), so a failed attempt is traceable to an address.
|
||||
remote, _ := towel.ClientIP(ctx)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
guard := publishedResolverGuard(app)
|
||||
if guard == nil || !wellFormedAPIToken(raw) || guard.store == nil {
|
||||
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
|
||||
return nil, nil, nil, ErrUnauthenticated
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
hash := hashAPIToken(raw)
|
||||
row, err := guard.store.FindUsableByHash(ctx, hash, now)
|
||||
if err != nil || row == nil {
|
||||
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, 0)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, 0)
|
||||
return nil, nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
|
||||
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
|
||||
return nil, nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if guard.users == nil {
|
||||
logTokenAuthAt("", resolverTokenPath, "", "failed", 0, row.ID)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "failed", 0, row.ID)
|
||||
return nil, nil, nil, ErrUnauthenticated
|
||||
}
|
||||
principal, err := guard.users.FindByID(ctx, row.BackendUserID)
|
||||
if err != nil || principal == nil {
|
||||
logTokenAuthAt("", resolverTokenPath, "", "failed", row.BackendUserID, row.ID)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "failed", row.BackendUserID, row.ID)
|
||||
return nil, nil, nil, ErrUnauthenticated
|
||||
}
|
||||
stored := row.Scopes.Get()
|
||||
@@ -58,7 +62,7 @@ func ResolveBackendToken(ctx context.Context, app *backpack.App, raw string) (*b
|
||||
// Personal API tokens keep the request locale (see authenticateToken).
|
||||
principal.PreferredLocale = ""
|
||||
stampLastUsed(ctx, guard.stamps, guard.store, row.ID, now)
|
||||
logTokenAuthAt("", resolverTokenPath, "", "success", principal.ID, row.ID)
|
||||
logTokenAuthAt("", resolverTokenPath, remote, "success", principal.ID, row.ID)
|
||||
return principal, row, append([]string(nil), stored...), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
|
||||
| `pact.MCPPersonalTokenResolver` | Published by the one plugin whose personal tokens may open an MCP session (one resolver per application): resolves a raw token into a `pact.MCPSubject` whose `TokenID` identifies the token row (it keys the session binding; a subject without one is refused); false when the prefix is not its own, which the endpoint answers as unauthorized. |
|
||||
| `pact.MCPLinkedBackendResolver` | Published by the plugin that owns the frontend-to-backend account link: the `pact.LinkedBackendUser` (id, login, display name) of a frontend user, false when unlinked. |
|
||||
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. |
|
||||
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. `TokenID` and `ClientID` narrow a revoked event: a token id ends only the sessions that personal token opened (subject with that `TokenID` and no `ClientID`), a client id only that OAuth client's sessions, neither every session of the subject; other kinds ignore them. |
|
||||
| `pact.FrontendPermission` | One frontend permission code a plugin declares: `Code`, `Label`, `Tab` and `Comment`, offered in the user plugin's permission editors next to the seeded codes. |
|
||||
| `pact.HasFrontendPermissions` | Declares frontend permission codes, the frontend counterpart of `pact.HasPermissions`. |
|
||||
| `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. |
|
||||
|
||||
@@ -272,10 +272,19 @@ type LinkedBackendUser struct {
|
||||
// deactivated (MCPSubjectDeleted). The MCP module re-lists the subject's
|
||||
// tools or ends its sessions; every call re-checks anyway, so a missed event
|
||||
// only leaves a stale list for longer.
|
||||
//
|
||||
// TokenID and ClientID narrow an MCPSubjectRevoked event. A non-zero TokenID
|
||||
// ends only the sessions whose subject under Guard is UserID with that
|
||||
// TokenID and no ClientID: the sessions one personal token opened. A
|
||||
// non-empty ClientID ends only the sessions whose subject under Guard is
|
||||
// UserID with that ClientID: one OAuth client's grant. With neither, every
|
||||
// session of the subject ends. Other kinds ignore both fields.
|
||||
type MCPSubjectChanged struct {
|
||||
Guard string
|
||||
UserID uint
|
||||
Kind string
|
||||
Guard string
|
||||
UserID uint
|
||||
Kind string
|
||||
TokenID uint
|
||||
ClientID string
|
||||
}
|
||||
|
||||
// Kinds of MCPSubjectChanged.
|
||||
|
||||
@@ -38,7 +38,8 @@ Use cases:
|
||||
- Stateful sessions (D-25, D-27): `initialize` opens a session (429 above `mcp.sessions.max_per_binding`), a request with an unknown id answers 404 (the client re-initialises), another identity presenting the id answers 403 `session_binding`, DELETE ends the session everywhere, the record's TTL slides on every request, a session initialised on one instance is rehydrated on another from the stored `ServerSessionState`. A standalone GET stream re-verifies its bearer every minute and ends when it no longer resolves to the session's binding or the session's record is gone.
|
||||
- Session reaper (A-CR-01): most clients exit without DELETE, so a janitor started at mount checks every local session against the store every `min(mcp.sessions.idle_ttl/2, 1m)` and closes each local copy whose record has expired (its SDK server session ends and it leaves the fan-out set), logging the `expired` step. The store's TTL decides on every instance: a session another instance keeps alive still has its record, so no instance closes it. No configuration key; the reaper runs for the life of the process.
|
||||
- Session stores: `memory` (one instance; records in the process) or `redis` (records `<prefix>mcp:s:<id>` with `PX` TTLs, per-binding sets `<prefix>mcp:b:<binding>`, events on channel `<prefix>mcp:events`, under `cooler.KeyPrefix`); Mount never waits for Redis: a background warm-up opens the client from `redis.url` with a backoff (one second doubling to thirty, each failure logged) and subscribes to the event channel, so an instance that serves only stateless clients still receives fan-out, and the first request on any path also starts the open without waiting for it; while the store is down stateful requests answer 503 and stateless requests are served.
|
||||
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once.
|
||||
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once. A revocation naming `TokenID` ends only the sessions whose subject under the event's guard is that user with that token id and no client id (one personal token); one naming `ClientID` ends only that OAuth client's sessions of the user; one naming neither, and every deletion, ends all sessions of the subject.
|
||||
- Client address: before resolving a bearer, pier puts the client address on the resolvers' context with `towel.WithClientIP`, taken from the host when it offers one (surf applies `http.trusted_proxies`) or from the request's `RemoteAddr`, so a resolver can log a failed attempt with it.
|
||||
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. A server evicted from the LRU closes its sessions, and a stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
|
||||
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event.
|
||||
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
|
||||
@@ -107,7 +108,7 @@ An MCP client then connects to `https://example.com/mcp` with an OAuth access to
|
||||
| `pier.MaxDescriptionRunes` | The cap on a tool or prompt description (1024). |
|
||||
| `pier.Store` | The session registry and event channel: `Put`, `Get`, `Touch`, `Delete`, `Count`, `Publish`, `Subscribe`. |
|
||||
| `pier.Record` | One stored session: binding, session state JSON and creation time. |
|
||||
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id and publishing instance. |
|
||||
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id, publishing instance, and the optional token id and client id that narrow a revocation. |
|
||||
| `pier.LogComponent` | `pier`, the diary component of the audit lines. |
|
||||
| `pier.PRMScopes` | The `scopes_supported` list of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`). |
|
||||
|
||||
@@ -137,7 +138,7 @@ Operational notes: a single instance (the demo deployment) runs on the memory st
|
||||
|
||||
- `github.com/modelcontextprotocol/go-sdk` v1.8.0 (`mcp`, `auth`, `oauthex`, `jsonrpc`): the server, sessions, streamable HTTP transport, bearer middleware and metadata types; decided in `.planning/notes/2026-10-22-mcp-go-sdk.md`.
|
||||
- `github.com/google/jsonschema-go` v0.4.3: input and output schema reflection and argument validation.
|
||||
- Framework: `backpack`, `pact`, `party`, `bouncer`, `cabana`, `compass`, `cooler`, `diary`.
|
||||
- Framework: `backpack`, `pact`, `party`, `bouncer`, `cabana`, `compass`, `cooler`, `diary`, `towel`.
|
||||
|
||||
Only this module (and surf's tagged `pier_on.go`) imports the SDK; `pact` stays free of it.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func (p *Pier) listenEvents() {
|
||||
if ev == nil || ev.Kind == "" {
|
||||
return nil
|
||||
}
|
||||
event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance}
|
||||
event := Event{Kind: ev.Kind, Guard: ev.Guard, UserID: ev.UserID, Instance: p.instance, TokenID: ev.TokenID, ClientID: ev.ClientID}
|
||||
if err := p.store.Publish(ctx, event); err != nil {
|
||||
p.handleEvent(event)
|
||||
}
|
||||
@@ -31,7 +31,9 @@ func (p *Pier) listenEvents() {
|
||||
// the tools and prompts of every local session and stateless server of
|
||||
// that subject, which makes the SDK send the list_changed notifications; a
|
||||
// revocation or deletion closes those sessions, drops their records and
|
||||
// evicts the stateless servers. A link event also drops the link cache.
|
||||
// evicts the stateless servers. A revocation naming a TokenID or a ClientID
|
||||
// closes only the sessions that token or client opened (revokedMatch). A
|
||||
// link event also drops the link cache.
|
||||
func (p *Pier) handleEvent(ev Event) {
|
||||
ctx := context.Background()
|
||||
switch ev.Kind {
|
||||
@@ -40,13 +42,14 @@ func (p *Pier) handleEvent(ev Event) {
|
||||
p.sessions.closeLocal(ev.SessionID)
|
||||
}
|
||||
case pact.MCPSubjectRevoked, pact.MCPSubjectDeleted:
|
||||
for _, ls := range p.sessions.matching(ev.Guard, ev.UserID) {
|
||||
match := revokedMatch(ev)
|
||||
for _, ls := range p.sessions.matchingFunc(match) {
|
||||
_ = p.store.Delete(ctx, ls.id)
|
||||
p.sessions.closeLocal(ls.id)
|
||||
logSession(logger(), "revoked", ls.id, ls.binding)
|
||||
}
|
||||
if p.stateless != nil {
|
||||
p.stateless.evict(ev.Guard, ev.UserID)
|
||||
p.stateless.evictFunc(match)
|
||||
}
|
||||
case pact.MCPSubjectLink, pact.MCPSubjectPermissions:
|
||||
if ev.Kind == pact.MCPSubjectLink {
|
||||
@@ -67,6 +70,28 @@ func (p *Pier) handleEvent(ev Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// revokedMatch selects the identities a revoked or deleted event ends
|
||||
// (P-31). A revoked event with a TokenID matches the identities whose
|
||||
// subject under ev.Guard is ev.UserID with that TokenID and no ClientID (the
|
||||
// sessions of one personal token); one with a ClientID matches the subject
|
||||
// with that ClientID (one OAuth client); otherwise, and for every deleted
|
||||
// event, any identity that includes the subject.
|
||||
func revokedMatch(ev Event) func(Identity) bool {
|
||||
if ev.Kind == pact.MCPSubjectRevoked && (ev.TokenID != 0 || ev.ClientID != "") {
|
||||
return func(id Identity) bool {
|
||||
s := id.subjectFor(ev.Guard)
|
||||
if s == nil || s.UserID != ev.UserID {
|
||||
return false
|
||||
}
|
||||
if ev.TokenID != 0 {
|
||||
return s.TokenID == ev.TokenID && s.ClientID == ""
|
||||
}
|
||||
return s.ClientID == ev.ClientID
|
||||
}
|
||||
}
|
||||
return func(id Identity) bool { return id.has(ev.Guard, ev.UserID) }
|
||||
}
|
||||
|
||||
// recomputeSession re-filters a local session's identity (re-resolving
|
||||
// the backend link after a link event) and applies the difference with
|
||||
// AddTool, RemoveTools, AddPrompt and RemovePrompts.
|
||||
@@ -158,12 +183,17 @@ func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts
|
||||
|
||||
// matching lists the local sessions whose identity includes the subject.
|
||||
func (sr *sessionRouter) matching(guard string, userID uint) []*localSession {
|
||||
return sr.matchingFunc(func(id Identity) bool { return id.has(guard, userID) })
|
||||
}
|
||||
|
||||
// matchingFunc lists the local sessions whose identity satisfies match.
|
||||
func (sr *sessionRouter) matchingFunc(match func(Identity) bool) []*localSession {
|
||||
sr.mu.Lock()
|
||||
defer sr.mu.Unlock()
|
||||
var out []*localSession
|
||||
for _, ls := range sr.local {
|
||||
ls.mu.Lock()
|
||||
hit := ls.identity.has(guard, userID)
|
||||
hit := match(ls.identity)
|
||||
ls.mu.Unlock()
|
||||
if hit {
|
||||
out = append(out, ls)
|
||||
|
||||
@@ -18,6 +18,14 @@ type Host interface {
|
||||
Owner(method, path string) (string, bool)
|
||||
}
|
||||
|
||||
// clientIPHost is optional for a Host: a host that knows the application's
|
||||
// trusted proxies answers the client address of a request, which pier puts
|
||||
// on the token resolvers' context (towel.WithClientIP). Without it pier uses
|
||||
// the host part of the request's RemoteAddr.
|
||||
type clientIPHost interface {
|
||||
ClientIP(*http.Request) string
|
||||
}
|
||||
|
||||
// RouteInfo is one registered route as the Host reports it: the HTTP method,
|
||||
// the pattern as registered (with {param} segments), the plugin that
|
||||
// registered it and its middleware names in order.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
)
|
||||
|
||||
@@ -249,6 +251,7 @@ func (p *Pier) bodyLimit(next http.Handler) http.Handler {
|
||||
// protected resource metadata URL and the read scope.
|
||||
func (p *Pier) bearer(next http.Handler) http.Handler {
|
||||
verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
|
||||
ctx = towel.WithClientIP(ctx, p.clientIP(r))
|
||||
id, err := p.identities.resolve(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err)
|
||||
@@ -269,6 +272,19 @@ func (p *Pier) bearer(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP is the request's client address: the host's answer when it
|
||||
// implements clientIPHost (surf applies http.trusted_proxies), otherwise the
|
||||
// host part of RemoteAddr, or RemoteAddr itself when it has no port.
|
||||
func (p *Pier) clientIP(r *http.Request) string {
|
||||
if h, ok := p.host.(clientIPHost); ok {
|
||||
return h.ClientIP(r)
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// challengeWriter completes the 401 challenge with scope="read", which the
|
||||
// SDK middleware would otherwise enforce on every request.
|
||||
type challengeWriter struct {
|
||||
|
||||
@@ -198,12 +198,17 @@ func closeSessions(srv *mcp.Server) {
|
||||
|
||||
// matching lists the entries whose identity includes the subject.
|
||||
func (sp *statelessPath) matching(guard string, userID uint) []*statelessEntry {
|
||||
return sp.matchingFunc(func(id Identity) bool { return id.has(guard, userID) })
|
||||
}
|
||||
|
||||
// matchingFunc lists the entries whose identity satisfies match.
|
||||
func (sp *statelessPath) matchingFunc(match func(Identity) bool) []*statelessEntry {
|
||||
sp.mu.Lock()
|
||||
defer sp.mu.Unlock()
|
||||
var out []*statelessEntry
|
||||
for _, e := range sp.entries {
|
||||
e.mu.Lock()
|
||||
hit := e.identity.has(guard, userID)
|
||||
hit := match(e.identity)
|
||||
e.mu.Unlock()
|
||||
if hit {
|
||||
out = append(out, e)
|
||||
@@ -215,7 +220,13 @@ func (sp *statelessPath) matching(guard string, userID uint) []*statelessEntry {
|
||||
// evict drops the entries of a subject (a revocation or deletion) and
|
||||
// closes their sessions, so a subscriptions/listen stream ends.
|
||||
func (sp *statelessPath) evict(guard string, userID uint) {
|
||||
for _, e := range sp.matching(guard, userID) {
|
||||
sp.evictFunc(func(id Identity) bool { return id.has(guard, userID) })
|
||||
}
|
||||
|
||||
// evictFunc drops the entries whose identity satisfies match and closes
|
||||
// their sessions.
|
||||
func (sp *statelessPath) evictFunc(match func(Identity) bool) {
|
||||
for _, e := range sp.matchingFunc(match) {
|
||||
sp.mu.Lock()
|
||||
if cur, ok := sp.entries[e.binding]; ok && cur == e {
|
||||
sp.order.Remove(e.elem)
|
||||
|
||||
@@ -25,6 +25,8 @@ type Event struct {
|
||||
UserID uint `json:"user_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
TokenID uint `json:"token_id,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
}
|
||||
|
||||
// EventEnded is the kind of a session-ended event.
|
||||
|
||||
@@ -5,6 +5,7 @@ package surf
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -19,13 +20,22 @@ import (
|
||||
// handler lookup and the two mount kinds without pier importing surf.
|
||||
func init() {
|
||||
mountPier = func(r *Router, app *backpack.App, plugins []party.Plugin) error {
|
||||
return pier.Mount(pierHost{r: r}, app, plugins)
|
||||
return pier.Mount(pierHost{r: r, trusted: TrustedProxies(app.Config)}, app, plugins)
|
||||
}
|
||||
}
|
||||
|
||||
// pierHost adapts a Router to pier.Host.
|
||||
// pierHost adapts a Router to pier.Host. trusted holds http.trusted_proxies
|
||||
// for ClientIP.
|
||||
type pierHost struct {
|
||||
r *Router
|
||||
r *Router
|
||||
trusted []netip.Prefix
|
||||
}
|
||||
|
||||
// ClientIP answers the request's client address with the application's
|
||||
// trusted proxies applied, the same rule the throttle middleware uses; pier
|
||||
// puts it on the token resolvers' context.
|
||||
func (h pierHost) ClientIP(req *http.Request) string {
|
||||
return ClientIP(req, h.trusted)
|
||||
}
|
||||
|
||||
func (h pierHost) Routes() []pier.RouteInfo {
|
||||
|
||||
@@ -22,7 +22,7 @@ Use cases:
|
||||
|
||||
## Features
|
||||
|
||||
- Four independent string values, each with a setter and a getter: actor (`towel.WithActor`, `towel.Actor`), organization (`towel.WithOrganization`, `towel.Organization`), collection (`towel.WithCollection`, `towel.Collection`) and locale (`towel.WithLocale`, `towel.Locale`).
|
||||
- Five independent string values, each with a setter and a getter: actor (`towel.WithActor`, `towel.Actor`), organization (`towel.WithOrganization`, `towel.Organization`), collection (`towel.WithCollection`, `towel.Collection`), locale (`towel.WithLocale`, `towel.Locale`) and client address (`towel.WithClientIP`, `towel.ClientIP`).
|
||||
- Unexported context key types, so no other package can read or overwrite the values by accident.
|
||||
- Getters return `(value, ok)`: a value that was set to an empty string is distinguishable from one that was never set.
|
||||
- Nil-safe: a setter given a nil context starts from `context.Background()`, and a getter given a nil context reports `false`.
|
||||
@@ -66,6 +66,7 @@ func listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
| `towel.WithOrganization` / `towel.Organization` | Store and read the organization (tenant) the request belongs to. |
|
||||
| `towel.WithCollection` / `towel.Collection` | Store and read the collection the request operates on. |
|
||||
| `towel.WithLocale` / `towel.Locale` | Store and read the locale used for translations and localized responses. |
|
||||
| `towel.WithClientIP` / `towel.ClientIP` | Store and read the request's client address for code below the HTTP layer, such as a token resolver logging a failed attempt. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ type actorKey struct{}
|
||||
type organizationKey struct{}
|
||||
type collectionKey struct{}
|
||||
type localeKey struct{}
|
||||
type clientIPKey struct{}
|
||||
|
||||
func withValue(ctx context.Context, key, value any) context.Context {
|
||||
if ctx == nil {
|
||||
@@ -61,3 +62,16 @@ func WithLocale(ctx context.Context, locale string) context.Context {
|
||||
func Locale(ctx context.Context) (string, bool) {
|
||||
return stringValue(ctx, localeKey{})
|
||||
}
|
||||
|
||||
// WithClientIP stores the request's client address on ctx, for code that
|
||||
// runs below the HTTP layer (for example a token resolver logging a failed
|
||||
// attempt). The caller decides how the address was derived (trusted proxy
|
||||
// headers or the socket peer).
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
return withValue(ctx, clientIPKey{}, ip)
|
||||
}
|
||||
|
||||
// ClientIP returns the client address stored by WithClientIP.
|
||||
func ClientIP(ctx context.Context) (string, bool) {
|
||||
return stringValue(ctx, clientIPKey{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user