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/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). diff --git a/README.md b/README.md index 83c1ae1..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 @@ -151,7 +152,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 +161,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,15 +224,96 @@ 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. | 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 | +| `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) | @@ -260,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. +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 a24b465..f141e79 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) @@ -87,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, 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 == "" { + 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/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..0593895 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,21 @@ 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"` + // 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"` @@ -155,17 +167,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 { @@ -221,6 +246,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 } @@ -280,6 +311,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{} @@ -340,6 +383,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{} @@ -358,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{} @@ -482,12 +538,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 } @@ -495,12 +549,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 } 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..d8e339e 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" @@ -27,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 } @@ -35,28 +37,30 @@ 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, } } 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 { @@ -99,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, @@ -124,6 +131,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 +152,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/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) + } + }) + } +} 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;