From af76a9d48a3acb8b624bd83a1e192e6248bee516 Mon Sep 17 00:00:00 2001 From: des Date: Fri, 1 May 2026 16:36:55 +0200 Subject: [PATCH 1/4] Track created_by identity per entry Records the caller's identity on each stored entry, sourced from a JWT identity claim (default sub, configurable via auth.jwt.identity_claim) or the name of a matched named_tokens config entry. Unnamed static tokens and disabled-auth requests remain anonymous. Also extends release CI to publish docker images for pre-release semver tags (e.g. v1.4.0-alpha.1) without moving the latest tag, and runs ci.yml on all branches so feature branches get vet/test/build. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 + README.md | 86 +++++++++++++- cmd/server/main.go | 12 +- config.yaml | 8 ++ examples/auth0.yaml | 50 ++++++++ examples/keycloak.yaml | 54 +++++++++ internal/auth/auth.go | 41 ++++--- internal/auth/auth_test.go | 84 +++++++++++--- internal/auth/identity.go | 25 ++++ internal/auth/jwt.go | 67 +++++++++-- internal/auth/jwt_test.go | 127 ++++++++++++++++++++- internal/config/config.go | 41 +++++-- internal/domain/entry.go | 1 + internal/mcp/handlers.go | 23 +++- internal/repository/postgres.go | 20 ++-- internal/repository/sqlite.go | 38 +++--- internal/service/entry_service.go | 4 + internal/service/entry_service_test.go | 37 ++++++ internal/service/file_service.go | 28 +++-- migrations/postgres/004_add_created_by.sql | 1 + migrations/sqlite/004_add_created_by.sql | 1 + 22 files changed, 652 insertions(+), 100 deletions(-) create mode 100644 examples/auth0.yaml create mode 100644 examples/keycloak.yaml create mode 100644 internal/auth/identity.go create mode 100644 migrations/postgres/004_add_created_by.sql create mode 100644 migrations/sqlite/004_add_created_by.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a298639..bba2399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: ['**'] pull_request: branches: [main] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6eedfb..3c99409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,8 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=auto tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/README.md b/README.md index 83c1ae1..a6fabe1 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ All settings live in `config.yaml`. Every value can be overridden with environme | Setting | Env var | Default | Description | |---------|---------|---------|-------------| | `auth.enabled` | `WORDSTORE_AUTH_ENABLED` | `true` | Enable/disable authentication | -| `auth.tokens` | `WORDSTORE_AUTH_TOKENS` | | Comma-separated bearer tokens | +| `auth.tokens` | `WORDSTORE_AUTH_TOKENS` | | Comma-separated bearer tokens (anonymous — `created_by` left null) | +| `auth.named_tokens` | | | List of `{name, token}` pairs. Requests using a named token record `created_by = name`. See [Tracking who created an entry](#tracking-who-created-an-entry). | | | `WORDSTORE_AUTH_TOKEN` | | Token for stdio transport validation | #### JWT / JWKS @@ -159,8 +160,10 @@ All settings live in `config.yaml`. Every value can be overridden with environme | Setting | Env var | Default | Description | |---------|---------|---------|-------------| | `auth.jwt.jwks_url` | `WORDSTORE_AUTH_JWT_JWKS_URL` | | JWKS endpoint for public key discovery (required when `jwt` block is present) | -| `auth.jwt.issuer` | `WORDSTORE_AUTH_JWT_ISSUER` | | Expected `iss` claim | +| `auth.jwt.issuer` | `WORDSTORE_AUTH_JWT_ISSUER` | | Expected `iss` claim (exact match — Auth0 emits a trailing slash, Keycloak does not) | | `auth.jwt.audience` | `WORDSTORE_AUTH_JWT_AUDIENCE` | | Expected `aud` claim (recommended for OAuth — RFC 8707) | +| `auth.jwt.required_scopes` | `WORDSTORE_AUTH_JWT_REQUIRED_SCOPES` | | Comma-separated scopes that must all be present; checked against the `scope` claim (space-delimited string) and `scp` claim (array). If unset, signature + iss + aud is enough. | +| `auth.jwt.identity_claim` | `WORDSTORE_AUTH_JWT_IDENTITY_CLAIM` | `sub` | Claim used to populate `created_by` on stored entries. Common alternatives: `email`, `preferred_username`. | #### Protected Resource Metadata (RFC 9728) @@ -220,6 +223,85 @@ Register these redirect URIs in your authorization server for Claude: - `https://claude.com/api/mcp/auth_callback` - `http://localhost:6274/oauth/callback` (Claude Code) +#### Provider-specific notes + +Watchword validates one IdP at a time. Pick Keycloak **or** Auth0 (or a Cloudflare Workers OAuth provider); ready-made configs live in [`examples/keycloak.yaml`](examples/keycloak.yaml) and [`examples/auth0.yaml`](examples/auth0.yaml). + +**Keycloak** + +- `jwks_url`: `{base}/realms/{realm}/protocol/openid-connect/certs` +- `issuer`: `{base}/realms/{realm}` (no trailing slash) +- `audience`: by default Keycloak puts `account` in `aud`. Treating that as a valid audience accepts any realm user — it does not bind tokens to this API. Create an *Audience* client scope that emits a custom value (e.g. `watchword`), assign it to your client, and set `audience` to that value. +- Scopes: define client scopes (e.g. `watchword:read`, `watchword:write`) and require them with `required_scopes`. Keycloak sends them in the `scope` claim. + +**Auth0** + +- `jwks_url`: `https://{tenant}.auth0.com/.well-known/jwks.json` +- `issuer`: `https://{tenant}.auth0.com/` (trailing slash — Auth0 emits it and `iss` is matched exactly) +- `audience`: the API Identifier from the Auth0 API (e.g. `https://watchword.example.com/api`) +- Scopes: define them on the Auth0 API and grant to the app/user; Auth0 sends them in the `scope` claim. + +**Cloudflare** + +Two distinct Cloudflare products show up here — they behave differently: + +- **Cloudflare Workers OAuth Provider** (the pattern in [`docs/cloudflare-worker-oauth-proxy.md`](docs/cloudflare-worker-oauth-proxy.md)) — fits Watchword's bearer-token flow. The Worker issues its own JWTs; `aud` is whatever the Worker stamps. Set `audience` to that value, point `jwks_url`/`issuer` at the Worker's well-known endpoints, and treat it the same as Auth0/Keycloak. +- **Cloudflare Access** (zero-trust app gating) — does *not* fit cleanly. Access delivers its JWT in the `Cf-Access-Jwt-Assertion` header (and cookie), not `Authorization: Bearer`. `aud` is the per-application *Application AUD* tag (a hex string from the Access dashboard); JWKS is at `https://.cloudflareaccess.com/cdn-cgi/access/certs` and issuer is `https://.cloudflareaccess.com`. Best used as a front door layered in front of normal MCP auth, not as the MCP auth itself. + +#### How `aud` is shaped per provider + +`aud` is enforced via `jwt.WithAudience` (exact match), which accepts either a string or an array — only one entry has to match. Per-provider gotchas: + +| Provider | Where `aud` comes from | Shape | What to put in `audience` | +|----------|-----------------------|-------|----------------------------| +| Keycloak | Audience protocol mapper on a client scope | string or array (often includes `"account"`) | The custom value emitted by your Audience mapper (e.g. `"watchword"`). Don't set this to `"account"` — that accepts every realm user. | +| Auth0 | The `audience` query param sent to `/authorize` | array, typically `[, "https://{tenant}.auth0.com/userinfo"]` | The API Identifier (matches the first array entry). If clients omit `audience` at `/authorize`, Auth0 returns an opaque token (not a JWT) — those won't validate. | +| Cloudflare Worker OAuth | Whatever the Worker code stamps | depends on the Worker | The exact value the Worker uses. | +| Cloudflare Access | The Application AUD tag | string (hex) | The Application AUD from the Access dashboard. Note Access uses a non-`Authorization` header, so swapping it in requires middleware changes. | + +#### Tracking who created an entry + +Watchword records the creator's identity on each new entry in a nullable `created_by` column. The column is surfaced on `get_entry`, `get_entry_by_word`, `list_entries`, `search_entries`, `search_words`, `store_entry`, `restore_entry`, and `upload_file` responses. It stays `null` for anonymous calls. + +Where `created_by` comes from: + +- **JWT requests:** the value of `auth.jwt.identity_claim` (default `sub`). Set it to `email` or `preferred_username` if you want a human-readable label. +- **Named static tokens:** the `name` of the matching `auth.named_tokens` entry. +- **Plain `auth.tokens`:** anonymous (`created_by` is null) — these tokens have no associated identity. +- **Auth disabled:** anonymous. + +```yaml +auth: + enabled: true + named_tokens: + - name: ci-bot + token: "ci-secret-xyz" + - name: alice + token: "alice-secret-xyz" + jwt: + jwks_url: "https://{tenant}.auth0.com/.well-known/jwks.json" + issuer: "https://{tenant}.auth0.com/" + audience: "https://watchword.example.com/api" + identity_claim: "email" +``` + +Restoring an expired entry preserves the original `created_by`; it is not overwritten by the restorer. + +#### Required scopes + +`auth.jwt.required_scopes` enforces fine-grained access on top of issuer/audience. Every listed scope must appear in either the `scope` claim (space-delimited string, used by Auth0 and Keycloak) or the `scp` claim (array). Without this, any valid token from the configured issuer/audience is accepted. + +```yaml +auth: + jwt: + jwks_url: "https://{tenant}.auth0.com/.well-known/jwks.json" + issuer: "https://{tenant}.auth0.com/" + audience: "https://watchword.example.com/api" + required_scopes: + - "watchword:read" + - "watchword:write" +``` + ### S3 file storage (optional) When configured, Watchword registers `upload_file` and `download_file` tools. Files are stored in S3 (or any S3-compatible service like Cloudflare R2) and transferred via presigned URLs — the MCP server never touches file data. diff --git a/cmd/server/main.go b/cmd/server/main.go index a24b465..8be0673 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,7 +52,7 @@ func main() { os.Exit(1) } - authenticator := auth.NewAuthenticator(cfg.Auth.Enabled, cfg.Auth.Tokens) + authenticator := auth.NewAuthenticator(cfg.Auth.Enabled, cfg.Auth.Tokens, cfg.Auth.NamedTokens) if cfg.Auth.JWT != nil { jwtVal, err := auth.NewJWTValidator(ctx, cfg.Auth.JWT) @@ -75,11 +75,17 @@ func main() { if cfg.Server.Transport == "stdio" && cfg.Auth.Enabled { token := os.Getenv("WORDSTORE_AUTH_TOKEN") - if err := authenticator.Validate(token); err != nil { + identity, err := authenticator.Validate(token) + if err != nil { logger.Error("stdio auth failed: invalid WORDSTORE_AUTH_TOKEN") os.Exit(1) } - logger.Info("stdio auth validated successfully") + if identity != "" { + ctx = auth.WithIdentity(ctx, identity) + logger.Info("stdio auth validated successfully", "identity", identity) + } else { + logger.Info("stdio auth validated successfully") + } } svc := service.NewEntryService(repo, cfg.Expiration.TTLHours, logger) diff --git a/config.yaml b/config.yaml index cf6d928..67ef1d6 100644 --- a/config.yaml +++ b/config.yaml @@ -14,6 +14,9 @@ auth: enabled: true tokens: - "your-secret-token-1" + # named_tokens: # Optional. Each entry's `name` is recorded as `created_by` for that token's requests. + # - name: "ci-bot" + # token: "ci-secret-xyz" # resource_metadata: # RFC 9728 Protected Resource Metadata (MCP OAuth) # resource: "https://watchword.example.com" # authorization_servers: @@ -24,6 +27,11 @@ auth: # jwks_url: "https://auth.example.com/.well-known/jwks.json" # issuer: "https://auth.example.com/" # audience: "https://watchword.example.com" + # required_scopes: # Optional. All listed scopes must appear in `scope` or `scp` claim. + # - "watchword:read" + # - "watchword:write" + # identity_claim: "sub" # Optional. Claim used to populate `created_by`. Default: "sub". Common alternative: "email". + # See examples/keycloak.yaml and examples/auth0.yaml for ready-made provider configs. # oauth_metadata: # Legacy AS metadata (2025-03-26 MCP spec compat) # authorization_endpoint: "https://auth.example.com/authorize" # token_endpoint: "https://auth.example.com/oauth/token" diff --git a/examples/auth0.yaml b/examples/auth0.yaml new file mode 100644 index 0000000..4f42638 --- /dev/null +++ b/examples/auth0.yaml @@ -0,0 +1,50 @@ +# Watchword + Auth0 +# +# Plug Watchword into an Auth0 tenant. Replace placeholders: +# {TENANT} Auth0 tenant subdomain, e.g. "acme" or "acme.eu" +# {API_ID} the API Identifier you set on your Auth0 API +# (e.g. "https://watchword.example.com/api") +# {WATCHWORD} public URL of this MCP server (e.g. https://watchword.example.com) +# +# Issuer trap: Auth0 emits `iss` with a trailing slash. Match it exactly. +# +# Scopes: declare scopes (e.g. `watchword:read`, `watchword:write`) on the +# Auth0 API and grant them to the application/user. Auth0 puts them in the +# `scope` claim (space-delimited string). + +server: + transport: "streamable-http" + http_port: 8080 + +database: + driver: "postgres" + postgres: + dsn: "postgres://wordstore:wordstore@localhost:5432/wordstore?sslmode=disable" + +auth: + enabled: true + + resource_metadata: + resource: "{WATCHWORD}" + authorization_servers: + - "https://{TENANT}.auth0.com/" + scopes_supported: + - "watchword:read" + - "watchword:write" + + jwt: + jwks_url: "https://{TENANT}.auth0.com/.well-known/jwks.json" + issuer: "https://{TENANT}.auth0.com/" # trailing slash matters + audience: "{API_ID}" + required_scopes: + - "watchword:read" + identity_claim: "sub" # Auth0 `sub` looks like "auth0|abc123". Use "email" for human-readable created_by (requires the openid email scope). + +expiration: + enabled: true + interval_hours: 24 + ttl_hours: 168 + +logging: + level: "info" + format: "json" diff --git a/examples/keycloak.yaml b/examples/keycloak.yaml new file mode 100644 index 0000000..683c629 --- /dev/null +++ b/examples/keycloak.yaml @@ -0,0 +1,54 @@ +# Watchword + Keycloak +# +# Plug Watchword into a Keycloak realm. Replace placeholders: +# {KC_BASE} e.g. https://keycloak.example.com +# {REALM} Keycloak realm name (e.g. "watchword") +# {WATCHWORD} public URL of this MCP server (e.g. https://watchword.example.com) +# +# Audience trap: by default Keycloak puts "account" in `aud`, which means any +# authenticated realm user passes the audience check. To bind tokens to this +# API, create an "Audience" client scope that emits a custom audience (e.g. +# "watchword") and assign it to the client. Then set `audience` below to that +# value. +# +# Scopes: create client scopes like `watchword:read` / `watchword:write` and +# require them via `required_scopes`. Both `scope` (string) and `scp` (array) +# claims are supported. + +server: + transport: "streamable-http" + http_port: 8080 + +database: + driver: "postgres" + postgres: + dsn: "postgres://wordstore:wordstore@localhost:5432/wordstore?sslmode=disable" + +auth: + enabled: true + + # MCP clients discover the authorization server here (RFC 9728). + resource_metadata: + resource: "{WATCHWORD}" + authorization_servers: + - "{KC_BASE}/realms/{REALM}" + scopes_supported: + - "watchword:read" + - "watchword:write" + + jwt: + jwks_url: "{KC_BASE}/realms/{REALM}/protocol/openid-connect/certs" + issuer: "{KC_BASE}/realms/{REALM}" + audience: "watchword" # the value emitted by your Audience client scope + required_scopes: + - "watchword:read" + identity_claim: "preferred_username" # `sub` is a UUID in Keycloak — `preferred_username` or `email` is friendlier for `created_by` + +expiration: + enabled: true + interval_hours: 24 + ttl_hours: 168 + +logging: + level: "info" + format: "json" diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fd5a836..5525189 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,20 +6,23 @@ import ( "net/http" "strings" + "github.com/watchword/watchword/internal/config" "github.com/watchword/watchword/internal/domain" ) type Authenticator struct { enabled bool tokens []string + namedTokens []config.NamedToken jwt *JWTValidator resourceMetadataURL string } -func NewAuthenticator(enabled bool, tokens []string) *Authenticator { +func NewAuthenticator(enabled bool, tokens []string, named []config.NamedToken) *Authenticator { return &Authenticator{ - enabled: enabled, - tokens: tokens, + enabled: enabled, + tokens: tokens, + namedTokens: named, } } @@ -33,28 +36,36 @@ func (a *Authenticator) SetResourceMetadataURL(url string) { a.resourceMetadataURL = url } -func (a *Authenticator) Validate(token string) error { +// Validate verifies the bearer token and returns the caller's identity. +// Identity is the JWT identity claim (e.g. `sub`) for OAuth tokens, the +// configured name for a `named_tokens` entry, or "" for an unnamed static +// token / when auth is disabled. An empty identity means anonymous, not +// failure — failures return ErrUnauthorized. +func (a *Authenticator) Validate(token string) (string, error) { if !a.enabled { - return nil + return "", nil } if token == "" { - return domain.ErrUnauthorized + return "", domain.ErrUnauthorized } - // If the token looks like a JWT (has 2 dots) and we have a JWT validator, try JWT first if a.jwt != nil && strings.Count(token, ".") == 2 { - if err := a.jwt.Validate(token); err == nil { - return nil + if id, err := a.jwt.Validate(token); err == nil { + return id, nil } } - // Fall back to plain token comparison + for _, nt := range a.namedTokens { + if subtle.ConstantTimeCompare([]byte(token), []byte(nt.Token)) == 1 { + return nt.Name, nil + } + } for _, valid := range a.tokens { if subtle.ConstantTimeCompare([]byte(token), []byte(valid)) == 1 { - return nil + return "", nil } } - return domain.ErrUnauthorized + return "", domain.ErrUnauthorized } func (a *Authenticator) HTTPMiddleware(next http.Handler) http.Handler { @@ -82,11 +93,15 @@ func (a *Authenticator) HTTPMiddleware(next http.Handler) http.Handler { return } - if err := a.Validate(token); err != nil { + identity, err := a.Validate(token) + if err != nil { a.writeUnauthorized(w, "invalid auth token") return } + if identity != "" { + r = r.WithContext(WithIdentity(r.Context(), identity)) + } next.ServeHTTP(w, r) }) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 5218cdd..7c5902e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -15,32 +15,70 @@ import ( ) func TestAuthenticator_Validate(t *testing.T) { - a := NewAuthenticator(true, []string{"token1", "token2"}) + a := NewAuthenticator(true, []string{"token1", "token2"}, nil) - if err := a.Validate("token1"); err != nil { + if _, err := a.Validate("token1"); err != nil { t.Errorf("valid token1 should pass: %v", err) } - if err := a.Validate("token2"); err != nil { + if _, err := a.Validate("token2"); err != nil { t.Errorf("valid token2 should pass: %v", err) } - if err := a.Validate("invalid"); err == nil { + if _, err := a.Validate("invalid"); err == nil { t.Error("invalid token should fail") } - if err := a.Validate(""); err == nil { + if _, err := a.Validate(""); err == nil { t.Error("empty token should fail") } } func TestAuthenticator_Disabled(t *testing.T) { - a := NewAuthenticator(false, nil) - if err := a.Validate(""); err != nil { + a := NewAuthenticator(false, nil, nil) + if _, err := a.Validate(""); err != nil { t.Errorf("disabled auth should pass: %v", err) } - if err := a.Validate("anything"); err != nil { + if _, err := a.Validate("anything"); err != nil { t.Errorf("disabled auth should pass: %v", err) } } +func TestAuthenticator_NamedTokens(t *testing.T) { + a := NewAuthenticator(true, nil, []config.NamedToken{ + {Name: "ci-bot", Token: "secret-1"}, + {Name: "alice", Token: "secret-2"}, + }) + + id, err := a.Validate("secret-1") + if err != nil { + t.Fatalf("valid named token should pass: %v", err) + } + if id != "ci-bot" { + t.Errorf("expected identity 'ci-bot', got %q", id) + } + + id, err = a.Validate("secret-2") + if err != nil { + t.Fatalf("valid named token should pass: %v", err) + } + if id != "alice" { + t.Errorf("expected identity 'alice', got %q", id) + } + + if _, err := a.Validate("nope"); err == nil { + t.Error("unknown token should fail") + } +} + +func TestAuthenticator_PlainTokenIsAnonymous(t *testing.T) { + a := NewAuthenticator(true, []string{"plain"}, nil) + id, err := a.Validate("plain") + if err != nil { + t.Fatalf("plain token should pass: %v", err) + } + if id != "" { + t.Errorf("plain (unnamed) token should yield anonymous identity, got %q", id) + } +} + func TestAuthenticator_JWTDispatch(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -57,25 +95,33 @@ func TestAuthenticator_JWTDispatch(t *testing.T) { } defer jwtVal.Close() - a := NewAuthenticator(true, []string{"plain-token"}) + a := NewAuthenticator(true, []string{"plain-token"}, nil) a.SetJWTValidator(jwtVal) - // Valid JWT should pass + // Valid JWT should pass and yield the sub claim as identity jwtToken := signToken(t, key, kid, jwt.MapClaims{ "sub": "user1", "exp": time.Now().Add(time.Hour).Unix(), }) - if err := a.Validate(jwtToken); err != nil { + id, err := a.Validate(jwtToken) + if err != nil { t.Errorf("valid JWT should pass: %v", err) } + if id != "user1" { + t.Errorf("expected identity 'user1', got %q", id) + } - // Plain token should still work - if err := a.Validate("plain-token"); err != nil { + // Plain token should still work (anonymous) + id, err = a.Validate("plain-token") + if err != nil { t.Errorf("plain token should pass: %v", err) } + if id != "" { + t.Errorf("plain token should be anonymous, got %q", id) + } // Invalid token should fail - if err := a.Validate("bad-token"); err == nil { + if _, err := a.Validate("bad-token"); err == nil { t.Error("invalid token should fail") } } @@ -97,17 +143,17 @@ func TestAuthenticator_JWTFallbackToPlain(t *testing.T) { defer jwtVal.Close() // Use a plain token that looks like a JWT (has 2 dots) - a := NewAuthenticator(true, []string{"a.b.c"}) + a := NewAuthenticator(true, []string{"a.b.c"}, nil) a.SetJWTValidator(jwtVal) // "a.b.c" has 2 dots, so JWT is tried first but fails, then falls back to plain - if err := a.Validate("a.b.c"); err != nil { + if _, err := a.Validate("a.b.c"); err != nil { t.Errorf("plain token with dots should fall back to plain validation: %v", err) } } func TestHTTPMiddleware_SkipsWellKnown(t *testing.T) { - a := NewAuthenticator(true, []string{"secret"}) + a := NewAuthenticator(true, []string{"secret"}, nil) handler := a.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -148,7 +194,7 @@ func TestHTTPMiddleware_SkipsWellKnown(t *testing.T) { } func TestHTTPMiddleware_WWWAuthenticate_Plain(t *testing.T) { - a := NewAuthenticator(true, []string{"secret"}) + a := NewAuthenticator(true, []string{"secret"}, nil) handler := a.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -168,7 +214,7 @@ func TestHTTPMiddleware_WWWAuthenticate_Plain(t *testing.T) { } func TestHTTPMiddleware_WWWAuthenticate_WithResourceMetadata(t *testing.T) { - a := NewAuthenticator(true, []string{"secret"}) + a := NewAuthenticator(true, []string{"secret"}, nil) a.SetResourceMetadataURL("https://watchword.example.com/.well-known/oauth-protected-resource") handler := a.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..3874546 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,25 @@ +package auth + +import "context" + +type identityKey struct{} + +// WithIdentity returns a context carrying the caller's identity (typically a +// JWT claim like `sub`/`email`, or the name of a named static token). +// An empty id is treated as anonymous and not stored. +func WithIdentity(ctx context.Context, id string) context.Context { + if id == "" { + return ctx + } + return context.WithValue(ctx, identityKey{}, id) +} + +// IdentityFrom extracts the caller's identity from ctx. Returns ("", false) +// when the request is anonymous (e.g. unnamed static token, or auth disabled). +func IdentityFrom(ctx context.Context) (string, bool) { + id, ok := ctx.Value(identityKey{}).(string) + if !ok || id == "" { + return "", false + } + return id, true +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 6d0b793..15217e1 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "strings" "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" @@ -11,9 +12,11 @@ import ( ) type JWTValidator struct { - jwks keyfunc.Keyfunc - parser *jwt.Parser - cancel context.CancelFunc + jwks keyfunc.Keyfunc + parser *jwt.Parser + requiredScopes []string + identityClaim string + cancel context.CancelFunc } func NewJWTValidator(ctx context.Context, cfg *config.JWTConfig) (*JWTValidator, error) { @@ -35,17 +38,63 @@ func NewJWTValidator(ctx context.Context, cfg *config.JWTConfig) (*JWTValidator, opts = append(opts, jwt.WithAudience(cfg.Audience)) } + identityClaim := cfg.IdentityClaim + if identityClaim == "" { + identityClaim = "sub" + } + return &JWTValidator{ - jwks: jwks, - parser: jwt.NewParser(opts...), - cancel: cancel, + jwks: jwks, + parser: jwt.NewParser(opts...), + requiredScopes: cfg.RequiredScopes, + identityClaim: identityClaim, + cancel: cancel, }, nil } -func (v *JWTValidator) Validate(tokenStr string) error { - _, err := v.parser.Parse(tokenStr, v.jwks.KeyfuncCtx(context.Background())) +// Validate verifies the token's signature, issuer, audience and required +// scopes. On success it returns the caller's identity, read from the +// configured claim (default `sub`). Identity is "" when the claim is missing. +func (v *JWTValidator) Validate(tokenStr string) (string, error) { + token, err := v.parser.Parse(tokenStr, v.jwks.KeyfuncCtx(context.Background())) if err != nil { - return fmt.Errorf("jwt validation failed: %w", err) + return "", fmt.Errorf("jwt validation failed: %w", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("jwt validation failed: unexpected claims type") + } + if len(v.requiredScopes) > 0 { + if err := checkScopes(claims, v.requiredScopes); err != nil { + return "", fmt.Errorf("jwt validation failed: %w", err) + } + } + identity, _ := claims[v.identityClaim].(string) + return identity, nil +} + +// checkScopes ensures every required scope is present in the token's +// `scope` (space-delimited string, used by Auth0 and Keycloak) or `scp` +// (array, used by some other IdPs) claim. +func checkScopes(claims jwt.MapClaims, required []string) error { + granted := map[string]struct{}{} + switch s := claims["scope"].(type) { + case string: + for _, p := range strings.Fields(s) { + granted[p] = struct{}{} + } + } + if arr, ok := claims["scp"].([]any); ok { + for _, item := range arr { + if s, ok := item.(string); ok { + granted[s] = struct{}{} + } + } + } + for _, want := range required { + if _, ok := granted[want]; !ok { + return fmt.Errorf("missing required scope %q", want) + } } return nil } diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 6325b59..feecd31 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -100,7 +100,7 @@ func TestJWTValidator_ValidToken(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), }) - if err := v.Validate(token); err != nil { + if _, err := v.Validate(token); err != nil { t.Errorf("valid token should pass: %v", err) } } @@ -126,7 +126,7 @@ func TestJWTValidator_ExpiredToken(t *testing.T) { "exp": time.Now().Add(-time.Hour).Unix(), }) - if err := v.Validate(token); err == nil { + if _, err := v.Validate(token); err == nil { t.Error("expired token should fail") } } @@ -154,7 +154,7 @@ func TestJWTValidator_WrongIssuer(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), }) - if err := v.Validate(token); err == nil { + if _, err := v.Validate(token); err == nil { t.Error("token with wrong issuer should fail") } } @@ -182,11 +182,128 @@ func TestJWTValidator_WrongAudience(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), }) - if err := v.Validate(token); err == nil { + if _, err := v.Validate(token); err == nil { t.Error("token with wrong audience should fail") } } +func TestJWTValidator_RequiredScopes_ScopeString(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + kid := "test-key-1" + srv := setupJWKSServer(t, &key.PublicKey, kid) + + v, err := NewJWTValidator(context.Background(), &config.JWTConfig{ + JWKSURL: srv.URL, + RequiredScopes: []string{"watchword:read", "watchword:write"}, + }) + if err != nil { + t.Fatal(err) + } + defer v.Close() + + good := signToken(t, key, kid, jwt.MapClaims{ + "sub": "user1", + "exp": time.Now().Add(time.Hour).Unix(), + "scope": "watchword:read watchword:write extra", + }) + if _, err := v.Validate(good); err != nil { + t.Errorf("token with required scopes should pass: %v", err) + } + + bad := signToken(t, key, kid, jwt.MapClaims{ + "sub": "user1", + "exp": time.Now().Add(time.Hour).Unix(), + "scope": "watchword:read", + }) + if _, err := v.Validate(bad); err == nil { + t.Error("token missing required scope should fail") + } + + none := signToken(t, key, kid, jwt.MapClaims{ + "sub": "user1", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, err := v.Validate(none); err == nil { + t.Error("token with no scope claim should fail when scopes required") + } +} + +func TestJWTValidator_RequiredScopes_ScpArray(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + kid := "test-key-1" + srv := setupJWKSServer(t, &key.PublicKey, kid) + + v, err := NewJWTValidator(context.Background(), &config.JWTConfig{ + JWKSURL: srv.URL, + RequiredScopes: []string{"watchword:read"}, + }) + if err != nil { + t.Fatal(err) + } + defer v.Close() + + good := signToken(t, key, kid, jwt.MapClaims{ + "sub": "user1", + "exp": time.Now().Add(time.Hour).Unix(), + "scp": []any{"watchword:read", "other"}, + }) + if _, err := v.Validate(good); err != nil { + t.Errorf("token with required scope in scp should pass: %v", err) + } +} + +func TestJWTValidator_IdentityClaim(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + kid := "test-key-1" + srv := setupJWKSServer(t, &key.PublicKey, kid) + + // Default: sub + defaultV, err := NewJWTValidator(context.Background(), &config.JWTConfig{JWKSURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + defer defaultV.Close() + + tok := signToken(t, key, kid, jwt.MapClaims{ + "sub": "auth0|abc", + "email": "alice@example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + id, err := defaultV.Validate(tok) + if err != nil { + t.Fatalf("validate: %v", err) + } + if id != "auth0|abc" { + t.Errorf("default identity should be sub claim, got %q", id) + } + + // Custom: email + emailV, err := NewJWTValidator(context.Background(), &config.JWTConfig{ + JWKSURL: srv.URL, + IdentityClaim: "email", + }) + if err != nil { + t.Fatal(err) + } + defer emailV.Close() + id, err = emailV.Validate(tok) + if err != nil { + t.Fatalf("validate: %v", err) + } + if id != "alice@example.com" { + t.Errorf("identity_claim=email should yield email, got %q", id) + } +} + func TestJWTValidator_InvalidSignature(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -213,7 +330,7 @@ func TestJWTValidator_InvalidSignature(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), }) - if err := v.Validate(token); err == nil { + if _, err := v.Validate(token); err == nil { t.Error("token with wrong signature should fail") } } diff --git a/internal/config/config.go b/internal/config/config.go index 0f8a6f3..6e5ae33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,17 +155,30 @@ type PostgresConfig struct { } type AuthConfig struct { - Enabled bool `yaml:"enabled"` - Tokens []string `yaml:"tokens"` - JWT *JWTConfig `yaml:"jwt"` - OAuthMetadata *OAuthMetadataConfig `yaml:"oauth_metadata"` - ResourceMetadata *ResourceMetadataConfig `yaml:"resource_metadata"` + Enabled bool `yaml:"enabled"` + Tokens []string `yaml:"tokens"` + NamedTokens []NamedToken `yaml:"named_tokens"` + JWT *JWTConfig `yaml:"jwt"` + OAuthMetadata *OAuthMetadataConfig `yaml:"oauth_metadata"` + ResourceMetadata *ResourceMetadataConfig `yaml:"resource_metadata"` +} + +// NamedToken associates a static bearer token with a label that is recorded +// as `created_by` on entries the holder creates. +type NamedToken struct { + Name string `yaml:"name"` + Token string `yaml:"token"` } type JWTConfig struct { - JWKSURL string `yaml:"jwks_url"` - Issuer string `yaml:"issuer"` - Audience string `yaml:"audience"` + JWKSURL string `yaml:"jwks_url"` + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + RequiredScopes []string `yaml:"required_scopes"` + // IdentityClaim is the claim used to populate `created_by` on stored + // entries. Defaults to "sub". Common alternatives: "email", + // "preferred_username". + IdentityClaim string `yaml:"identity_claim"` } type OAuthMetadataConfig struct { @@ -280,6 +293,18 @@ func applyEnvOverrides(cfg *Config) { } cfg.Auth.JWT.Audience = v } + if v := os.Getenv("WORDSTORE_AUTH_JWT_REQUIRED_SCOPES"); v != "" { + if cfg.Auth.JWT == nil { + cfg.Auth.JWT = &JWTConfig{} + } + cfg.Auth.JWT.RequiredScopes = strings.Split(v, ",") + } + if v := os.Getenv("WORDSTORE_AUTH_JWT_IDENTITY_CLAIM"); v != "" { + if cfg.Auth.JWT == nil { + cfg.Auth.JWT = &JWTConfig{} + } + cfg.Auth.JWT.IdentityClaim = v + } if v := os.Getenv("WORDSTORE_AUTH_OAUTH_AUTHORIZATION_ENDPOINT"); v != "" { if cfg.Auth.OAuthMetadata == nil { cfg.Auth.OAuthMetadata = &OAuthMetadataConfig{} diff --git a/internal/domain/entry.go b/internal/domain/entry.go index 2ff40ff..75cd5c3 100644 --- a/internal/domain/entry.go +++ b/internal/domain/entry.go @@ -61,6 +61,7 @@ type Entry struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` } // FileMetadata is stored as JSON in Payload for file entries. diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 5cd819a..68f69f7 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -48,6 +48,9 @@ func (h *Handlers) StoreEntry(ctx context.Context, req mcp.CallToolRequest) (*mc if result.Entry.ExpiresAt != nil { resp["expires_at"] = result.Entry.ExpiresAt } + if result.Entry.CreatedBy != nil { + resp["created_by"] = *result.Entry.CreatedBy + } if result.CollisionResolved { resp["original_word"] = result.OriginalWord } @@ -81,6 +84,9 @@ func (h *Handlers) GetEntry(ctx context.Context, req mcp.CallToolRequest) (*mcp. if entry.ExpiresAt != nil { resp["expires_at"] = entry.ExpiresAt } + if entry.CreatedBy != nil { + resp["created_by"] = *entry.CreatedBy + } return marshalResult(resp) } return marshalResult(entry) @@ -113,6 +119,9 @@ func (h *Handlers) GetEntryByWord(ctx context.Context, req mcp.CallToolRequest) if entry.ExpiresAt != nil { resp["expires_at"] = entry.ExpiresAt } + if entry.CreatedBy != nil { + resp["created_by"] = *entry.CreatedBy + } return marshalResult(resp) } return marshalResult(entry) @@ -173,6 +182,9 @@ func (h *Handlers) RestoreEntry(ctx context.Context, req mcp.CallToolRequest) (* if result.Entry.ExpiresAt != nil { resp["expires_at"] = result.Entry.ExpiresAt } + if result.Entry.CreatedBy != nil { + resp["created_by"] = *result.Entry.CreatedBy + } if result.CollisionResolved { resp["original_word"] = result.OriginalWord } @@ -224,12 +236,16 @@ func (h *Handlers) SearchWords(ctx context.Context, req mcp.CallToolRequest) (*m words := make([]map[string]interface{}, 0, len(entries)) for _, e := range entries { - words = append(words, map[string]interface{}{ + item := map[string]interface{}{ "word": e.Word, "id": e.ID.String(), "status": e.Status, "entry_type": e.EntryType, - }) + } + if e.CreatedBy != nil { + item["created_by"] = *e.CreatedBy + } + words = append(words, item) } resp := map[string]interface{}{ @@ -282,6 +298,9 @@ func compactEntries(entries []*domain.Entry) []map[string]interface{} { if e.ExpiresAt != nil { item["expires_at"] = e.ExpiresAt } + if e.CreatedBy != nil { + item["created_by"] = *e.CreatedBy + } if e.EntryType == domain.EntryTypeText { // Include payload length as a hint item["payload_length"] = len(e.Payload) diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index 726f904..72b2a76 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -48,7 +48,7 @@ func (r *PostgresRepo) Migrate(ctx context.Context) error { return fmt.Errorf("creating schema_migrations table: %w", err) } - migrationFiles := []string{"001_init.sql", "002_add_entry_type.sql", "003_add_download_history.sql"} + migrationFiles := []string{"001_init.sql", "002_add_entry_type.sql", "003_add_download_history.sql", "004_add_created_by.sql"} for _, name := range migrationFiles { var count int err := r.q.QueryRow(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = $1`, name).Scan(&count) @@ -97,10 +97,10 @@ func (r *PostgresRepo) Store(ctx context.Context, entry *domain.Entry) (*domain. } _, err := r.q.Exec(ctx, - `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, entry.ID, entry.Word, entry.Payload, string(entry.Status), string(entry.EntryType), - entry.CreatedAt, entry.UpdatedAt, entry.ExpiresAt, + entry.CreatedAt, entry.UpdatedAt, entry.ExpiresAt, entry.CreatedBy, ) if err != nil { return nil, fmt.Errorf("inserting entry: %w", err) @@ -110,13 +110,13 @@ func (r *PostgresRepo) Store(ctx context.Context, entry *domain.Entry) (*domain. func (r *PostgresRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Entry, error) { row := r.q.QueryRow(ctx, - `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE id = $1`, id, + `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE id = $1`, id, ) return scanPgEntry(row) } func (r *PostgresRepo) GetByWord(ctx context.Context, word string, includeExpired bool) (*domain.Entry, error) { - query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE word = $1` + query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE word = $1` if !includeExpired { query += ` AND status = 'active'` } @@ -141,7 +141,7 @@ func (r *PostgresRepo) SearchByLike(ctx context.Context, pattern string, status return nil, 0, fmt.Errorf("counting entries: %w", err) } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY word ASC LIMIT $%d OFFSET $%d`, + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY word ASC LIMIT $%d OFFSET $%d`, where, idx, idx+1) args = append(args, limit, offset) @@ -174,7 +174,7 @@ func (r *PostgresRepo) List(ctx context.Context, status string, limit int, offse return nil, 0, fmt.Errorf("counting entries: %w", err) } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY %s %s LIMIT $%d OFFSET $%d`, + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY %s %s LIMIT $%d OFFSET $%d`, where, sortBy, sortOrder, idx, idx+1) args = append(args, limit, offset) @@ -274,7 +274,7 @@ func (r *PostgresRepo) WithTx(ctx context.Context, fn func(Repository) error) er func scanPgEntry(row pgx.Row) (*domain.Entry, error) { var e domain.Entry var status, entryType string - err := row.Scan(&e.ID, &e.Word, &e.Payload, &status, &entryType, &e.CreatedAt, &e.UpdatedAt, &e.ExpiresAt) + err := row.Scan(&e.ID, &e.Word, &e.Payload, &status, &entryType, &e.CreatedAt, &e.UpdatedAt, &e.ExpiresAt, &e.CreatedBy) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrNotFound @@ -291,7 +291,7 @@ func scanPgEntries(rows pgx.Rows) ([]*domain.Entry, error) { for rows.Next() { var e domain.Entry var status, entryType string - if err := rows.Scan(&e.ID, &e.Word, &e.Payload, &status, &entryType, &e.CreatedAt, &e.UpdatedAt, &e.ExpiresAt); err != nil { + if err := rows.Scan(&e.ID, &e.Word, &e.Payload, &status, &entryType, &e.CreatedAt, &e.UpdatedAt, &e.ExpiresAt, &e.CreatedBy); err != nil { return nil, fmt.Errorf("scanning entry: %w", err) } e.Status = domain.EntryStatus(status) diff --git a/internal/repository/sqlite.go b/internal/repository/sqlite.go index da570e3..879c5c3 100644 --- a/internal/repository/sqlite.go +++ b/internal/repository/sqlite.go @@ -43,7 +43,7 @@ func (r *SQLiteRepo) Migrate(ctx context.Context) error { return fmt.Errorf("creating schema_migrations table: %w", err) } - migrationFiles := []string{"001_init.sql", "002_add_entry_type.sql", "003_add_download_history.sql"} + migrationFiles := []string{"001_init.sql", "002_add_entry_type.sql", "003_add_download_history.sql", "004_add_created_by.sql"} for _, name := range migrationFiles { var count int err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, name).Scan(&count) @@ -96,10 +96,10 @@ func (r *SQLiteRepo) Store(ctx context.Context, entry *domain.Entry) (*domain.En } _, err := r.execContext(ctx, - `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, entry.ID.String(), entry.Word, entry.Payload, string(entry.Status), string(entry.EntryType), - now.Format(time.RFC3339), now.Format(time.RFC3339), expiresAt, + now.Format(time.RFC3339), now.Format(time.RFC3339), expiresAt, entry.CreatedBy, ) if err != nil { return nil, fmt.Errorf("inserting entry: %w", err) @@ -109,14 +109,14 @@ func (r *SQLiteRepo) Store(ctx context.Context, entry *domain.Entry) (*domain.En func (r *SQLiteRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Entry, error) { row := r.queryRowContext(ctx, - `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE id = ?`, + `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE id = ?`, id.String(), ) return scanSQLiteEntry(row) } func (r *SQLiteRepo) GetByWord(ctx context.Context, word string, includeExpired bool) (*domain.Entry, error) { - query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE word = ?` + query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE word = ?` if !includeExpired { query += ` AND status = 'active'` } @@ -141,7 +141,7 @@ func (r *SQLiteRepo) SearchByLike(ctx context.Context, pattern string, status st return nil, 0, fmt.Errorf("counting entries: %w", err) } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY word ASC LIMIT ? OFFSET ?`, where) + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY word ASC LIMIT ? OFFSET ?`, where) args = append(args, limit, offset) rows, err := r.queryContext(ctx, query, args...) @@ -177,7 +177,7 @@ func (r *SQLiteRepo) List(ctx context.Context, status string, limit int, offset return nil, 0, fmt.Errorf("counting entries: %w", err) } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY %s %s LIMIT ? OFFSET ?`, + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY %s %s LIMIT ? OFFSET ?`, where, sortBy, sortOrder) args = append(args, limit, offset) @@ -329,10 +329,10 @@ func (r *sqliteTxRepo) Store(ctx context.Context, entry *domain.Entry) (*domain. } _, err := r.tx.ExecContext(ctx, - `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, entry.ID.String(), entry.Word, entry.Payload, string(entry.Status), string(entry.EntryType), - now.Format(time.RFC3339), now.Format(time.RFC3339), expiresAt, + now.Format(time.RFC3339), now.Format(time.RFC3339), expiresAt, entry.CreatedBy, ) if err != nil { return nil, fmt.Errorf("inserting entry: %w", err) @@ -342,14 +342,14 @@ func (r *sqliteTxRepo) Store(ctx context.Context, entry *domain.Entry) (*domain. func (r *sqliteTxRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Entry, error) { row := r.tx.QueryRowContext(ctx, - `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE id = ?`, + `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE id = ?`, id.String(), ) return scanSQLiteEntry(row) } func (r *sqliteTxRepo) GetByWord(ctx context.Context, word string, includeExpired bool) (*domain.Entry, error) { - query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries WHERE word = ?` + query := `SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries WHERE word = ?` if !includeExpired { query += ` AND status = 'active'` } @@ -374,7 +374,7 @@ func (r *sqliteTxRepo) SearchByLike(ctx context.Context, pattern string, status return nil, 0, err } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY word ASC LIMIT ? OFFSET ?`, where) + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY word ASC LIMIT ? OFFSET ?`, where) args = append(args, limit, offset) rows, err := r.tx.QueryContext(ctx, query, args...) @@ -406,7 +406,7 @@ func (r *sqliteTxRepo) List(ctx context.Context, status string, limit int, offse return nil, 0, err } - query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at FROM entries %s ORDER BY %s %s LIMIT ? OFFSET ?`, + query := fmt.Sprintf(`SELECT id, word, payload, status, entry_type, created_at, updated_at, expires_at, created_by FROM entries %s ORDER BY %s %s LIMIT ? OFFSET ?`, where, sortBy, sortOrder) args = append(args, limit, offset) @@ -528,9 +528,10 @@ func scanSQLiteEntry(row scannable) (*domain.Entry, error) { var e domain.Entry var idStr, status, createdStr, updatedStr string var expiresStr *string + var createdBy *string var entryType string - err := row.Scan(&idStr, &e.Word, &e.Payload, &status, &entryType, &createdStr, &updatedStr, &expiresStr) + err := row.Scan(&idStr, &e.Word, &e.Payload, &status, &entryType, &createdStr, &updatedStr, &expiresStr, &createdBy) if err != nil { if err == sql.ErrNoRows { return nil, domain.ErrNotFound @@ -545,6 +546,7 @@ func scanSQLiteEntry(row scannable) (*domain.Entry, error) { e.ID = id e.Status = domain.EntryStatus(status) e.EntryType = domain.EntryType(entryType) + e.CreatedBy = createdBy if t, err := time.Parse(time.RFC3339, createdStr); err == nil { e.CreatedAt = t @@ -567,9 +569,10 @@ func scanSQLiteEntries(rows *sql.Rows) ([]*domain.Entry, error) { var e domain.Entry var idStr, status, createdStr, updatedStr string var expiresStr *string + var createdBy *string var entryType string - if err := rows.Scan(&idStr, &e.Word, &e.Payload, &status, &entryType, &createdStr, &updatedStr, &expiresStr); err != nil { + if err := rows.Scan(&idStr, &e.Word, &e.Payload, &status, &entryType, &createdStr, &updatedStr, &expiresStr, &createdBy); err != nil { return nil, fmt.Errorf("scanning entry: %w", err) } @@ -580,6 +583,7 @@ func scanSQLiteEntries(rows *sql.Rows) ([]*domain.Entry, error) { e.ID = id e.Status = domain.EntryStatus(status) e.EntryType = domain.EntryType(entryType) + e.CreatedBy = createdBy if t, err := time.Parse(time.RFC3339, createdStr); err == nil { e.CreatedAt = t diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go index cab634c..7a478cf 100644 --- a/internal/service/entry_service.go +++ b/internal/service/entry_service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" + "github.com/watchword/watchword/internal/auth" "github.com/watchword/watchword/internal/domain" "github.com/watchword/watchword/internal/repository" ) @@ -97,6 +98,9 @@ func (s *EntryService) StoreEntry(ctx context.Context, word string, payload stri Payload: payload, ExpiresAt: expiresAt, } + if id, ok := auth.IdentityFrom(ctx); ok { + entry.CreatedBy = &id + } created, err := txRepo.Store(ctx, entry) if err != nil { return err diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go index 968b3c3..c0da31c 100644 --- a/internal/service/entry_service_test.go +++ b/internal/service/entry_service_test.go @@ -6,6 +6,7 @@ import ( "os" "testing" + "github.com/watchword/watchword/internal/auth" "github.com/watchword/watchword/internal/domain" "github.com/watchword/watchword/internal/repository" ) @@ -43,6 +44,42 @@ func TestStoreEntry_Basic(t *testing.T) { } } +func TestStoreEntry_RecordsCreatedByFromContext(t *testing.T) { + svc := newTestService(t) + ctx := auth.WithIdentity(context.Background(), "alice@example.com") + + result, err := svc.StoreEntry(ctx, "rabbit", "payload", nil) + if err != nil { + t.Fatalf("StoreEntry: %v", err) + } + if result.Entry.CreatedBy == nil { + t.Fatal("expected CreatedBy to be set from context identity") + } + if *result.Entry.CreatedBy != "alice@example.com" { + t.Errorf("expected CreatedBy=alice@example.com, got %q", *result.Entry.CreatedBy) + } + + // Round-trip via GetEntry to confirm persistence + got, err := svc.GetEntry(context.Background(), result.Entry.ID.String()) + if err != nil { + t.Fatalf("GetEntry: %v", err) + } + if got.CreatedBy == nil || *got.CreatedBy != "alice@example.com" { + t.Errorf("CreatedBy not persisted: got %v", got.CreatedBy) + } +} + +func TestStoreEntry_AnonymousWhenNoIdentity(t *testing.T) { + svc := newTestService(t) + result, err := svc.StoreEntry(context.Background(), "rabbit", "payload", nil) + if err != nil { + t.Fatalf("StoreEntry: %v", err) + } + if result.Entry.CreatedBy != nil { + t.Errorf("expected CreatedBy=nil for anonymous request, got %q", *result.Entry.CreatedBy) + } +} + func TestStoreEntry_CollisionResolution(t *testing.T) { svc := newTestService(t) ctx := context.Background() diff --git a/internal/service/file_service.go b/internal/service/file_service.go index fed63fe..693f8b7 100644 --- a/internal/service/file_service.go +++ b/internal/service/file_service.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/watchword/watchword/internal/auth" "github.com/watchword/watchword/internal/domain" "github.com/watchword/watchword/internal/repository" s3client "github.com/watchword/watchword/internal/s3" @@ -46,17 +47,18 @@ func NewFileService(repo repository.Repository, presigner s3client.Presigner, de } type UploadResult struct { - Word string `json:"word"` - ID string `json:"id"` - UploadURL string `json:"upload_url"` - ProxyUploadURL string `json:"proxy_upload_url,omitempty"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - MaxSize int64 `json:"max_size_bytes"` - CollisionResolved bool `json:"collision_resolved"` - OriginalWord string `json:"original_word,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` - Hint string `json:"hint"` + Word string `json:"word"` + ID string `json:"id"` + UploadURL string `json:"upload_url"` + ProxyUploadURL string `json:"proxy_upload_url,omitempty"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + MaxSize int64 `json:"max_size_bytes"` + CollisionResolved bool `json:"collision_resolved"` + OriginalWord string `json:"original_word,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + Hint string `json:"hint"` } type DownloadResult struct { @@ -124,6 +126,9 @@ func (s *FileService) UploadFile(ctx context.Context, word string, filename stri EntryType: domain.EntryTypeFile, ExpiresAt: expiresAt, } + if id, ok := auth.IdentityFrom(ctx); ok { + entry.CreatedBy = &id + } created, err := txRepo.Store(ctx, entry) if err != nil { return err @@ -142,6 +147,7 @@ func (s *FileService) UploadFile(ctx context.Context, word string, filename stri ContentType: contentType, MaxSize: s.maxFileSize, CollisionResolved: collision, + CreatedBy: created.CreatedBy, Hint: fmt.Sprintf("Upload your file with: curl -X PUT -H 'Content-Type: %s' -T '%s' '%s'", contentType, filename, uploadURL), } if s.proxySigner != nil { diff --git a/migrations/postgres/004_add_created_by.sql b/migrations/postgres/004_add_created_by.sql new file mode 100644 index 0000000..9b33724 --- /dev/null +++ b/migrations/postgres/004_add_created_by.sql @@ -0,0 +1 @@ +ALTER TABLE entries ADD COLUMN created_by TEXT; diff --git a/migrations/sqlite/004_add_created_by.sql b/migrations/sqlite/004_add_created_by.sql new file mode 100644 index 0000000..9b33724 --- /dev/null +++ b/migrations/sqlite/004_add_created_by.sql @@ -0,0 +1 @@ +ALTER TABLE entries ADD COLUMN created_by TEXT; From 9281963a5f563bb7d0bb0b5f7f49df633e3abcde Mon Sep 17 00:00:00 2001 From: des Date: Fri, 1 May 2026 17:11:56 +0200 Subject: [PATCH 2/4] Tolerate partial S3 config without failing startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial S3 configuration (e.g. WORDSTORE_S3_REGION leaking from a shared ConfigMap with no bucket or credentials) used to fail validate() or os.Exit() in s3client.NewClient, taking the whole MCP server down even though only the file tools depend on S3. Now: - validate() no longer requires bucket/region/proxy fields when the S3 block is non-nil; only defaults are applied. - main.go skips file-tool init with a WARN log if bucket/region are missing or NewClient fails, then keeps the server running with the text-based tools. - Proxy config is gated independently — missing hmac_secret/base_url disables only the proxy, presigned URLs still work. - s3.enabled: false / WORDSTORE_S3_ENABLED=false remains as the explicit kill switch for environments that may leak partial S3 env. --- README.md | 3 ++- cmd/server/main.go | 41 ++++++++++++++++++++++++++------------- internal/config/config.go | 35 +++++++++++++++++++++------------ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a6fabe1..f6eaa29 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ When configured, Watchword registers `upload_file` and `download_file` tools. Fi | Setting | Env var | Default | Description | |---------|---------|---------|-------------| +| `s3.enabled` | `WORDSTORE_S3_ENABLED` | *(unset)* | Set to `false` to force-disable S3 even if other `s3.*` / `WORDSTORE_S3_*` values are present. Useful for environments that may leak partial S3 env vars. | | `s3.endpoint` | `WORDSTORE_S3_ENDPOINT` | *(empty = AWS)* | Custom endpoint URL (required for R2, MinIO) | | `s3.region` | `WORDSTORE_S3_REGION` | | AWS region (e.g. `eu-central-1`) | | `s3.bucket` | `WORDSTORE_S3_BUCKET` | | S3 bucket name | @@ -342,7 +343,7 @@ s3: **S3 object cleanup**: Expired file entries do not auto-delete S3 objects. Use [S3 lifecycle rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) for garbage collection. -If `s3` is not configured, only the original text-based tools are registered — no S3 dependency. +If `s3` is not configured, only the original text-based tools are registered — no S3 dependency. To explicitly disable S3 in environments where partial `WORDSTORE_S3_*` env vars may leak in (e.g. shared k8s ConfigMaps), set `s3.enabled: false` (or `WORDSTORE_S3_ENABLED=false`) — the entire S3 block is then discarded after config load. ### Expiration diff --git a/cmd/server/main.go b/cmd/server/main.go index 8be0673..c9cbadd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -93,19 +93,34 @@ func main() { var fileSvc *service.FileService var s3c *s3client.Client if cfg.S3 != nil { - var err error - s3c, err = s3client.NewClient(ctx, cfg.S3) - if err != nil { - logger.Error("failed to initialize S3 client", "error", err) - os.Exit(1) - } - fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, logger) - logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region) - - if cfg.S3.Proxy != nil { - signer := proxy.NewURLSigner(cfg.S3.Proxy.BaseURL, cfg.S3.Proxy.HMACSecret, cfg.S3.Proxy.URLTTLMinutes) - fileSvc.SetProxySigner(signer) - logger.Info("proxy download enabled", "base_url", cfg.S3.Proxy.BaseURL, "ttl_minutes", cfg.S3.Proxy.URLTTLMinutes) + switch { + case cfg.S3.Bucket == "" || cfg.S3.Region == "": + logger.Warn("S3 config incomplete, file tools disabled", + "bucket_set", cfg.S3.Bucket != "", "region_set", cfg.S3.Region != "") + cfg.S3 = nil + default: + var err error + s3c, err = s3client.NewClient(ctx, cfg.S3) + if err != nil { + logger.Warn("S3 client init failed, file tools disabled", "error", err) + s3c = nil + cfg.S3 = nil + } else { + fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, logger) + logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region) + + if p := cfg.S3.Proxy; p != nil { + if p.HMACSecret == "" || p.BaseURL == "" { + logger.Warn("proxy config incomplete, proxy download disabled", + "hmac_secret_set", p.HMACSecret != "", "base_url_set", p.BaseURL != "") + cfg.S3.Proxy = nil + } else { + signer := proxy.NewURLSigner(p.BaseURL, p.HMACSecret, p.URLTTLMinutes) + fileSvc.SetProxySigner(signer) + logger.Info("proxy download enabled", "base_url", p.BaseURL, "ttl_minutes", p.URLTTLMinutes) + } + } + } } } diff --git a/internal/config/config.go b/internal/config/config.go index 6e5ae33..193044d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,12 @@ type Config struct { } type S3Config struct { + // Enabled explicitly toggles S3 file storage. When set to false the entire + // S3 block is discarded after config load — this is the safe way to ensure + // S3 stays off in environments that may leak partial WORDSTORE_S3_* env + // vars (e.g. shared k8s ConfigMaps). When unset (nil), S3 is enabled if + // the block is otherwise present. + Enabled *bool `yaml:"enabled"` Endpoint string `yaml:"endpoint"` Region string `yaml:"region"` Bucket string `yaml:"bucket"` @@ -234,6 +240,12 @@ func Load(path string) (*Config, error) { applyEnvOverrides(cfg) applyToolDefaults(cfg) + // Explicit S3 disable wins over any other S3 field that may have been + // populated by partial env vars or YAML — guarantees no S3 init. + if cfg.S3 != nil && cfg.S3.Enabled != nil && !*cfg.S3.Enabled { + cfg.S3 = nil + } + if err := validate(cfg); err != nil { return nil, err } @@ -365,6 +377,13 @@ func applyEnvOverrides(cfg *Config) { } // S3 env overrides + if v := os.Getenv("WORDSTORE_S3_ENABLED"); v != "" { + if cfg.S3 == nil { + cfg.S3 = &S3Config{} + } + b := strings.EqualFold(v, "true") || v == "1" + cfg.S3.Enabled = &b + } if v := os.Getenv("WORDSTORE_S3_ENDPOINT"); v != "" { if cfg.S3 == nil { cfg.S3 = &S3Config{} @@ -507,12 +526,10 @@ func validate(cfg *Config) error { return fmt.Errorf("expiration.ttl_hours must be >= 0") } if cfg.S3 != nil { - if cfg.S3.Bucket == "" { - return fmt.Errorf("s3.bucket is required when s3 is configured") - } - if cfg.S3.Region == "" { - return fmt.Errorf("s3.region is required when s3 is configured") - } + // Missing bucket/region or proxy hmac_secret/base_url are no longer + // fatal here — main() inspects these and disables file tools (with a + // warning) instead of failing startup, so the rest of the MCP server + // keeps working. if cfg.S3.PresignTTLMinutes <= 0 { cfg.S3.PresignTTLMinutes = 15 } @@ -520,12 +537,6 @@ func validate(cfg *Config) error { cfg.S3.MaxFileSizeBytes = 1073741824 // 1GB } if p := cfg.S3.Proxy; p != nil { - if p.HMACSecret == "" { - return fmt.Errorf("s3.proxy.hmac_secret is required when proxy is configured") - } - if p.BaseURL == "" { - return fmt.Errorf("s3.proxy.base_url is required when proxy is configured") - } if p.URLTTLMinutes <= 0 { p.URLTTLMinutes = 5 } From cee278a1cf48dd25c295a8610d60e0aa2bf4e4a7 Mon Sep 17 00:00:00 2001 From: des Date: Fri, 1 May 2026 21:44:26 +0200 Subject: [PATCH 3/4] Add S3 key prefix and README updates for 1.4.0 features Introduces optional s3.key_prefix (WORDSTORE_S3_KEY_PREFIX) to scope all new file uploads under a folder within the bucket (e.g. "tenants/acme//file"). Omitting the prefix preserves the existing bucket-root layout. Existing entries keep their stored S3 key. Also updates README features list and S3 section to document new parameters and graceful fallback behaviour introduced in this branch. --- README.md | 6 +++-- cmd/server/main.go | 4 +-- internal/config/config.go | 12 +++++++++ internal/service/file_service.go | 7 +++++- internal/service/file_service_test.go | 35 ++++++++++++++++++++++++++- 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f6eaa29..040438e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ LLM conversations are ephemeral. Watchword gives your assistant persistent, name - **Collision resolution** — if a keyword is taken, the server auto-appends a suffix (`rabbit` -> `rabbit2`) - **SQLite or PostgreSQL** backends - **Automatic expiration** — entries expire after a configurable TTL (or never, with `ttl_hours: 0`) -- **Bearer token and JWT/JWKS authentication** +- **Bearer token and JWT/JWKS authentication** — with optional named tokens for service accounts +- **Per-entry `created_by` tracking** — populated from a JWT identity claim or a named static token, surfaced on read/list/search responses - **Health endpoints** for Kubernetes liveness/readiness probes - **Customizable tool descriptions** — tune the prompts your LLM sees via `config.yaml` - **Transports**: stdio, SSE, Streamable HTTP, or combined HTTP mode @@ -312,6 +313,7 @@ When configured, Watchword registers `upload_file` and `download_file` tools. Fi | `s3.endpoint` | `WORDSTORE_S3_ENDPOINT` | *(empty = AWS)* | Custom endpoint URL (required for R2, MinIO) | | `s3.region` | `WORDSTORE_S3_REGION` | | AWS region (e.g. `eu-central-1`) | | `s3.bucket` | `WORDSTORE_S3_BUCKET` | | S3 bucket name | +| `s3.key_prefix` | `WORDSTORE_S3_KEY_PREFIX` | *(empty)* | Optional folder/prefix prepended to every new object key (e.g. `tenants/acme`). Existing entries keep their stored key. | | `s3.presign_ttl_minutes` | `WORDSTORE_S3_PRESIGN_TTL_MINUTES` | `15` | How long presigned URLs remain valid | | `s3.max_file_size_bytes` | `WORDSTORE_S3_MAX_FILE_SIZE_BYTES` | `1073741824` | Max file size (default 1GB) | | | `WORDSTORE_S3_ACCESS_KEY_ID` | | S3 access key (env var only — never in config file) | @@ -343,7 +345,7 @@ s3: **S3 object cleanup**: Expired file entries do not auto-delete S3 objects. Use [S3 lifecycle rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) for garbage collection. -If `s3` is not configured, only the original text-based tools are registered — no S3 dependency. To explicitly disable S3 in environments where partial `WORDSTORE_S3_*` env vars may leak in (e.g. shared k8s ConfigMaps), set `s3.enabled: false` (or `WORDSTORE_S3_ENABLED=false`) — the entire S3 block is then discarded after config load. +If `s3` is not configured, only the original text-based tools are registered — no S3 dependency. Partial S3 config (e.g. region set but no bucket, or missing credentials) does **not** fail startup — Watchword logs a warning and continues with the file tools disabled. To explicitly disable S3 in environments where partial `WORDSTORE_S3_*` env vars may leak in (e.g. shared k8s ConfigMaps), set `s3.enabled: false` (or `WORDSTORE_S3_ENABLED=false`) — the entire S3 block is then discarded after config load. ### Expiration diff --git a/cmd/server/main.go b/cmd/server/main.go index c9cbadd..f141e79 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -106,8 +106,8 @@ func main() { s3c = nil cfg.S3 = nil } else { - fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, logger) - logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region) + fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, cfg.S3.KeyPrefix, logger) + logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region, "key_prefix", cfg.S3.KeyPrefix) if p := cfg.S3.Proxy; p != nil { if p.HMACSecret == "" || p.BaseURL == "" { diff --git a/internal/config/config.go b/internal/config/config.go index 193044d..0593895 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,12 @@ type S3Config struct { Endpoint string `yaml:"endpoint"` Region string `yaml:"region"` Bucket string `yaml:"bucket"` + // KeyPrefix is an optional folder/prefix prepended to every S3 object key. + // Empty (default) keeps the legacy layout: "/" at + // bucket root. With prefix "tenants/acme" keys become + // "tenants/acme//". Existing entries keep their + // stored key — only new uploads use the prefix. + KeyPrefix string `yaml:"key_prefix"` PresignTTLMinutes int `yaml:"presign_ttl_minutes"` MaxFileSizeBytes int64 `yaml:"max_file_size_bytes"` Proxy *ProxyConfig `yaml:"proxy"` @@ -402,6 +408,12 @@ func applyEnvOverrides(cfg *Config) { } cfg.S3.Bucket = v } + if v := os.Getenv("WORDSTORE_S3_KEY_PREFIX"); v != "" { + if cfg.S3 == nil { + cfg.S3 = &S3Config{} + } + cfg.S3.KeyPrefix = v + } if v := os.Getenv("WORDSTORE_S3_PRESIGN_TTL_MINUTES"); v != "" { if cfg.S3 == nil { cfg.S3 = &S3Config{} diff --git a/internal/service/file_service.go b/internal/service/file_service.go index 693f8b7..d8e339e 100644 --- a/internal/service/file_service.go +++ b/internal/service/file_service.go @@ -28,6 +28,7 @@ type FileService struct { proxySigner ProxyURLSigner defaultTTL int // hours maxFileSize int64 + keyPrefix string // normalized: no leading/trailing slashes; "" = no prefix logger *slog.Logger } @@ -36,12 +37,13 @@ func (s *FileService) SetProxySigner(signer ProxyURLSigner) { s.proxySigner = signer } -func NewFileService(repo repository.Repository, presigner s3client.Presigner, defaultTTLHours int, maxFileSize int64, logger *slog.Logger) *FileService { +func NewFileService(repo repository.Repository, presigner s3client.Presigner, defaultTTLHours int, maxFileSize int64, keyPrefix string, logger *slog.Logger) *FileService { return &FileService{ repo: repo, presigner: presigner, defaultTTL: defaultTTLHours, maxFileSize: maxFileSize, + keyPrefix: strings.Trim(keyPrefix, "/"), logger: logger, } } @@ -101,6 +103,9 @@ func (s *FileService) UploadFile(ctx context.Context, word string, filename stri // up front and persist the entry with a single Store call inside the tx. entryID := uuid.New() s3Key := fmt.Sprintf("%s/%s", entryID.String(), filename) + if s.keyPrefix != "" { + s3Key = s.keyPrefix + "/" + s3Key + } meta := domain.FileMetadata{ S3Key: s3Key, Filename: filename, diff --git a/internal/service/file_service_test.go b/internal/service/file_service_test.go index 92620e7..35a2068 100644 --- a/internal/service/file_service_test.go +++ b/internal/service/file_service_test.go @@ -42,6 +42,10 @@ func (p *fakePresigner) DeleteObject(_ context.Context, key string) error { } func newTestFileService(t *testing.T) (*FileService, repository.Repository, *fakePresigner) { + return newTestFileServiceWithPrefix(t, "") +} + +func newTestFileServiceWithPrefix(t *testing.T, keyPrefix string) (*FileService, repository.Repository, *fakePresigner) { t.Helper() repo, err := repository.NewSQLiteRepo(":memory:") if err != nil { @@ -53,7 +57,7 @@ func newTestFileService(t *testing.T) (*FileService, repository.Repository, *fak } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) pres := &fakePresigner{} - svc := NewFileService(repo, pres, 168, 1<<20, logger) + svc := NewFileService(repo, pres, 168, 1<<20, keyPrefix, logger) return svc, repo, pres } @@ -140,3 +144,32 @@ func TestUploadFile_NoCollision(t *testing.T) { t.Errorf("meta.S3Key = %q, want %q", meta.S3Key, expectedKey) } } + +func TestUploadFile_KeyPrefix(t *testing.T) { + cases := []struct { + name string + prefix string + wantPfx string // expected normalized prefix in S3 key + }{ + {"plain", "tenants/acme", "tenants/acme/"}, + {"trailing slash trimmed", "tenants/acme/", "tenants/acme/"}, + {"leading slash trimmed", "/tenants/acme", "tenants/acme/"}, + {"both slashes trimmed", "/tenants/acme/", "tenants/acme/"}, + {"empty prefix = legacy", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, _, pres := newTestFileServiceWithPrefix(t, tc.prefix) + ctx := context.Background() + + result, err := svc.UploadFile(ctx, "fox", "report.txt", "text/plain", nil) + if err != nil { + t.Fatalf("UploadFile: %v", err) + } + wantKey := tc.wantPfx + result.ID + "/report.txt" + if len(pres.putKeys) != 1 || pres.putKeys[0] != wantKey { + t.Fatalf("PUT keys = %v, want [%s]", pres.putKeys, wantKey) + } + }) + } +} From 2f55683e0565ccc66db7bf12a524db613c357a24 Mon Sep 17 00:00:00 2001 From: des Date: Fri, 1 May 2026 21:46:37 +0200 Subject: [PATCH 4/4] Never add Claude as co-author in commits --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0ee0ff7..9ac4cb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ docker compose up -d # Run with PostgreSQL + Streamable HTTP (via supergatewa - **entry_type column** — `text` (default) or `file`; file entries store JSON metadata in payload field - **Streamable HTTP via supergateway** — Docker image wraps the stdio binary with [supergateway](https://github.com/supercorp-ai/supergateway) to expose Streamable HTTP on `/mcp`. Uses `--stateful` mode to avoid the child-process OOM leak ([PR #111](https://github.com/supercorp-ai/supergateway/pull/111)). Built from the PR #111 branch for the stateless leak fix as well. +## Commit Guidelines + +- Never add Claude as a co-author in commit messages (no `Co-Authored-By: Claude` lines). + ## MCP Library Uses `github.com/mark3labs/mcp-go` for MCP protocol support (stdio + SSE transports).