From 70396c321a23beca4fbe9025c9aca8210edca0fb Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Fri, 2 Oct 2026 21:44:26 +0200 Subject: [PATCH] changed deployment --- .github/copilot-instructions.md | 19 +++- Dockerfile | 2 + README.md | 92 ++++++++++++++--- backend/Dockerfile | 5 +- backend/go.mod | 7 +- backend/go.sum | 18 ++-- backend/internal/web/handler/handler.go | 3 +- backend/internal/web/handler/handler_test.go | 6 +- .../web/middleware/GoLooseMiddleware.go | 99 +++++++++++++++++++ .../web/middleware/GoLooseMiddleware_test.go | 97 ++++++++++++++++++ .../web/middleware/TenantSchemaMiddleware.go | 3 +- backend/internal/web/openapi/openapi.go | 10 +- backend/internal/web/router/router.go | 3 +- deploy/helm/go-guess/templates/NOTES.txt | 1 + .../go-guess/templates/app-deployment.yaml | 2 + deploy/helm/go-guess/values.yaml | 2 + deploy/nginx/README.md | 6 ++ docker-compose.yml | 28 ++++-- frontend/Dockerfile | 2 + frontend/README.md | 24 ++++- frontend/src/api/client.test.ts | 6 +- frontend/src/api/client.ts | 8 +- 22 files changed, 391 insertions(+), 52 deletions(-) create mode 100644 backend/internal/web/middleware/GoLooseMiddleware.go create mode 100644 backend/internal/web/middleware/GoLooseMiddleware_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 573141b..748ac28 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,10 +45,18 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use Keep feature-specific components inside their feature directory. Vite proxies `/api` to the Go service in development. - Local multi-tenant URLs use `.guess.local`. The frontend derives the - tenant from the hostname, calls `http://.guess.local:8080/api`, and + tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and sends `X-Tenant-Id` on every request type. Other deployments use same-origin `/api`. Generated participant links must use the validated matching tenant origin. Keep this contract documented in `README.md` and `frontend/README.md`. +- Protected interviewer routes also require Go Loose `X-API-Key` authorization + for application `guess`. The tenant passed to Go Loose comes from + `X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`; + `%s` is that tenant and must be one DNS label. Do not require the API key on + `/api/health`, `/api/openapi.json`, `/api/auth/login`, or opaque-token + participant routes. The frontend sends the key only when + `VITE_GO_LOOSE_API_KEY` is present at build time. Document both variables when + their contract changes. Goose files in `backend/migrations` are schema-only and run in every environment. `internal/database/seed/common.sql` contains shared reference data; @@ -56,10 +64,11 @@ Goose files in `backend/migrations` are schema-only and run in every environment Every entity change requires a migration plus corresponding fixture and integration test updates. Never put demo users or test records in schema migrations. -Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are -protected except `/api/health`, `/api/auth/login`, and opaque-token participant -interview routes. Never expose password hashes, CV/photo bytes, or interviewer -reference answers in public JSON. +Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Interviewer +routes additionally require a Go Loose API key for the request tenant. Health, +OpenAPI, login, and opaque-token participant interview routes do not. Never +expose password hashes, CV/photo bytes, interviewer reference answers, or API +keys in public JSON or logs. Questions are permanent reusable records linked to jobs through `job_posting_questions`; detach links rather than deleting questions. Deprecated diff --git a/Dockerfile b/Dockerfile index 9c78cb0..56a3dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ WORKDIR /src/frontend COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ +ARG VITE_GO_LOOSE_API_KEY +ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY RUN npm run build # Compile the API and seed binaries. diff --git a/README.md b/README.md index 08c868f..df0f961 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,22 @@ Add the local tenant hostnames once: ```text 127.0.0.1 nmbs.guess.local ypto.guess.local +127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev ``` -Open or . The -hostname's first label is the tenant ID: the frontend calls -`http://.guess.local:8080/api` and sends the same value in the +Start the shared TLS proxy described in +`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open + or . The hostname's first +label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the `X-Tenant-Id` header on JSON, multipart, file-download, and event-stream requests. The backend uses that tenant ID as the isolated PostgreSQL schema and builds participant invitation and meeting links from the matching tenant browser origin instead of the static localhost fallback. +Compose mounts the infrastructure CA from +`${LOCAL_DEV_CERTS_DIR:-/home/dnoulet/go/infrasctruture/certs}` into the API so +its HTTPS authorization calls to Go Loose validate normally. + Sign in with: - Email: `interviewer@ypto.local` @@ -49,23 +55,74 @@ make backend make frontend ``` -The API listens on port `8080`. Vite permits the documented local tenant -hostnames; the API CORS policy permits `*.guess.local` development origins and -the `X-Tenant-Id` request header. Non-local deployments keep using same-origin -`/api`. Set a different secure `JWT_SECRET` outside local development. +The API listens on port `8080` inside Docker and is exposed to browsers only +through Traefik and the frontend's same-origin `/api` proxy. Vite permits the +documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and +the `Authorization`, `X-API-Key`, and `X-Tenant-Id` request headers. Non-local +deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET` +outside local development. Protected interviewer calls also need a Go Loose API +key, documented below. ## API contract The backend exposes its current OpenAPI 3.0 contract at -. The document is assembled in memory +. The document is assembled in memory from the same typed registrations that add routes to Chi, including request and response schemas, status codes, authentication, tenant headers, path/query parameters, multipart uploads, downloads, and event streams. It therefore requires no generation command and cannot miss a newly registered route. `/api/openapi.json` and `/api/health` are system endpoints and do not require a -tenant header. All tenant data endpoints declare `X-Tenant-Id`; protected -operations additionally declare JWT bearer authentication. +tenant header. Every other route declares required `X-Tenant-Id`. Protected +operations also declare `apiKeyAuth` (`X-API-Key`) and `bearerAuth`. + +## Go Loose authorization + +Interviewer routes are authorized by Go Loose before the JWT check. The tenant +is read from the request `X-Tenant-Id` header and sent to Go Loose with +application slug `guess`. It is not taken from process configuration. The client +calls `POST /api/v1/authorize` on every protected request, removes `X-API-Key` +before the handler runs, and fails closed on a missing key, a denial, or a +control-plane error. There is no allow-on-error fallback. The client timeout is +2 seconds. + +`GO_LOOSE_BASE_URL` is the authorize base URL. The default is +`https://%s.auth.dev`. A single `%s` is replaced with the tenant, which must +then be one DNS label (`nmbs`, not `nmbs.auth.dev` or a host with a port). A +URL without `%s` is used unchanged, while the tenant still comes from the +header. Docker Compose passes this variable to the API. Helm sets it from +`app.goLooseBaseURL`. + +These routes do not require `X-API-Key` or a JWT: + +- `GET /api/health` and `GET /api/openapi.json` +- `POST /api/auth/login` +- opaque-token participant interview and meeting routes under + `/api/interviews/{token}` and `/api/participant-meetings/{token}` + +Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do +not. + +Issue one API key per tenant in the Go Loose console for the `guess` +application. The local installer seed creates that application for `nmbs` and +`ypto`. For a containerized frontend, pass the key at image build time: + +```bash +VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' docker compose build web +``` + +For Vite outside Docker, put the same variable in `frontend/.env.local`. That +file is ignored. Vite inlines `VITE_` variables into the browser bundle, so this +key is visible to anyone who can load the interviewer application. It identifies +the Guess application to Go Loose; it does not replace the interviewer JWT or +the participant token. Do not commit it or reuse a key across tenants. + +The published production image does not receive this build argument. A manual +production image can: + +```bash +docker build --build-arg VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' -t go-guess . +``` ## Quality commands @@ -126,7 +183,8 @@ docker build -t go-guess . The image serves the frontend and API on port `8080`. It applies database migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ, -and these environment variables must be provided: +and these environment variables must be provided. `GO_LOOSE_BASE_URL` is +optional and defaults to `https://%s.auth.dev`: ```bash docker run --rm -p 8080:8080 \ @@ -134,6 +192,7 @@ docker run --rm -p 8080:8080 \ -e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \ -e JWT_SECRET='replace-with-at-least-32-characters' \ -e FRONTEND_URL='https://go-guess.example.com' \ + -e GO_LOOSE_BASE_URL='https://%s.auth.dev' \ go-guess ``` @@ -160,6 +219,15 @@ For the repository's single-node Docker Rancher setup, run installs the default persistent StorageClass documented in `rancher-install.txt`. +The chart passes `app.goLooseBaseURL` to the API as `GO_LOOSE_BASE_URL`. The +default is `https://%s.auth.dev`. Override it when Go Loose is not reachable +at `.auth.dev`: + +```bash +helm upgrade --install go-guess deploy/helm/go-guess \ + --set-string app.goLooseBaseURL='https://%s.auth.example.com' +``` + Development uses demo fixtures, ephemeral database and message-broker storage, and NodePort `30080`. Production uses production fixtures, persistent volumes, generated secrets that are preserved across upgrades, and larger resource @@ -201,6 +269,8 @@ the registry in production when possible. ## Architecture Requests enter the Chi router and web handlers under `backend/internal/web`. +Tenant isolation runs first. The protected interviewer group then applies Go +Loose API-key authorization and JWT authentication. Web request/response models are translated to service models before business logic runs. Services own validation, CV trait extraction, and candidate scoring. The repository maps service models to GORM entities and persists them in PostgreSQL. diff --git a/backend/Dockerfile b/backend/Dockerfile index 3cd0634..a2ce9fa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,8 +3,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api \ - && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed + +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api/main.go \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed/main.go FROM golang:1.27.1-alpine AS tools RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 diff --git a/backend/go.mod b/backend/go.mod index 6544390..83aa22b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,10 +3,11 @@ module github.com/danyel/go-guess/backend go 1.27.1 require ( + github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.11.0 github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 github.com/pressly/goose/v3 v3.26.0 github.com/rabbitmq/amqp091-go v1.15.0 @@ -72,8 +73,8 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 155ee01..8d7e738 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,6 +22,8 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f h1:9GV60iNpwRsZONzZcFHsGxYNeU0iItiitOvFNLqlkkA= +github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -57,8 +59,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -119,8 +121,8 @@ github.com/rabbitmq/amqp091-go v1.15.0 h1:LEQL4/yp48/Wigt6A6XOu18RQRo8ZHtB5I/KZJ github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= @@ -166,8 +168,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -176,8 +178,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 2a5b999..204ed77 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/danyel/go-loose/client" "github.com/go-chi/chi/v5" "gorm.io/gorm" @@ -641,7 +642,7 @@ func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) { } func (h *Handler) frontendURLForRequest(r *http.Request) string { - tenant := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Tenant-Id"))) + tenant := strings.ToLower(strings.TrimSpace(r.Header.Get(client.XTenantId))) origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/") if tenant == "" || origin == "" { return h.frontendURL diff --git a/backend/internal/web/handler/handler_test.go b/backend/internal/web/handler/handler_test.go index fa46983..764675a 100644 --- a/backend/internal/web/handler/handler_test.go +++ b/backend/internal/web/handler/handler_test.go @@ -3,13 +3,15 @@ package handler import ( "net/http/httptest" "testing" + + "github.com/danyel/go-loose/client" ) func TestFrontendURLForTenantRequest(t *testing.T) { handler := &Handler{frontendURL: "http://localhost:5173"} request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request.Header.Set("Origin", "http://ypto.guess.local:5173") - request.Header.Set("X-Tenant-Id", "ypto") + request.Header.Set(client.XTenantId, "ypto") if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" { t.Fatalf("unexpected frontend URL %q", value) @@ -20,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) { handler := &Handler{frontendURL: "http://localhost:5173"} request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request.Header.Set("Origin", "http://nmbs.guess.local:5173") - request.Header.Set("X-Tenant-Id", "ypto") + request.Header.Set(client.XTenantId, "ypto") if value := handler.frontendURLForRequest(request); value != handler.frontendURL { t.Fatalf("expected configured frontend URL, got %q", value) diff --git a/backend/internal/web/middleware/GoLooseMiddleware.go b/backend/internal/web/middleware/GoLooseMiddleware.go new file mode 100644 index 0000000..bb81a99 --- /dev/null +++ b/backend/internal/web/middleware/GoLooseMiddleware.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/danyel/go-loose/client" +) + +const guessApplication = "guess" + +// A tenant used in the Go Loose host must be one DNS label. +var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`) + +// GoLooseMiddleware authorizes a request with Go Loose. The tenant is taken +// from the X-Tenant-Id header, not from process configuration. +// +// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The +// default is https://%s.auth.dev. A URL without %s is used as-is. +func GoLooseMiddleware() func(http.Handler) http.Handler { + pattern := os.Getenv("GO_LOOSE_BASE_URL") + if pattern == "" { + pattern = "https://%s.auth.dev" + } + httpClient := &http.Client{Timeout: 2 * time.Second} + var ( + mu sync.Mutex + clients = map[string]*client.Client{} + ) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenantID := strings.TrimSpace(r.Header.Get(client.XTenantId)) + if tenantID == "" { + writeGoLooseError(w, http.StatusBadRequest, "Missing X-Tenant-Id header") + return + } + baseURL, err := goLooseBaseURL(pattern, tenantID) + if err != nil { + writeGoLooseError(w, http.StatusBadRequest, "Invalid characters in tenant ID") + return + } + access, err := goLooseClient(&mu, clients, httpClient, baseURL, tenantID) + if err != nil { + slog.Error("configure go-loose client", "error", err) + writeGoLooseError(w, http.StatusInternalServerError, "authorization unavailable") + return + } + access.Middleware(next).ServeHTTP(w, r) + }) + } +} + +func goLooseBaseURL(pattern, tenantID string) (string, error) { + if !strings.Contains(pattern, "%s") { + return pattern, nil + } + if !safeTenantLabel.MatchString(tenantID) { + return "", fmt.Errorf("invalid tenant %q", tenantID) + } + return fmt.Sprintf(pattern, tenantID), nil +} + +func goLooseClient( + mu *sync.Mutex, + clients map[string]*client.Client, + httpClient *http.Client, + baseURL, tenantID string, +) (*client.Client, error) { + key := tenantID + "\x00" + baseURL + mu.Lock() + defer mu.Unlock() + if existing, ok := clients[key]; ok { + return existing, nil + } + created, err := client.New(client.Config{ + BaseURL: baseURL, + Tenant: tenantID, + Application: guessApplication, + HTTPClient: httpClient, + }) + if err != nil { + return nil, err + } + clients[key] = created + return created, nil +} + +func writeGoLooseError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/backend/internal/web/middleware/GoLooseMiddleware_test.go b/backend/internal/web/middleware/GoLooseMiddleware_test.go new file mode 100644 index 0000000..10953e5 --- /dev/null +++ b/backend/internal/web/middleware/GoLooseMiddleware_test.go @@ -0,0 +1,97 @@ +package middleware + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/danyel/go-loose/client" +) + +func TestGoLooseMiddlewareUsesTenantFromRequest(t *testing.T) { + const apiKey = "gl_test_key_that_is_long_enough_for_client" + var gotTenant, gotApplication, gotKey string + authorization := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get(client.DefaultHeader) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read authorization body: %v", err) + } + var payload map[string]string + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("decode authorization body: %v", err) + } + gotTenant = payload["tenant"] + gotApplication = payload["application"] + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"allowed":true,"tenant_slug":"ypto","application_slug":"guess","api_key_name":"guess"}`)) + })) + defer authorization.Close() + t.Setenv("GO_LOOSE_BASE_URL", authorization.URL) + + var principalTenant string + handler := GoLooseMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := client.PrincipalFromContext(r.Context()) + if !ok { + t.Fatal("missing Go Loose principal") + } + principalTenant = principal.TenantSlug + if r.Header.Get(client.DefaultHeader) != "" { + t.Fatal("API key was forwarded to the application") + } + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + request.Header.Set(client.XTenantId, "ypto") + request.Header.Set(client.DefaultHeader, apiKey) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if gotTenant != "ypto" || gotApplication != "guess" || gotKey != apiKey || principalTenant != "ypto" { + t.Fatalf("tenant=%q application=%q key=%q principal=%q", gotTenant, gotApplication, gotKey, principalTenant) + } +} + +func TestGoLooseMiddlewareRejectsMissingTenantAndKey(t *testing.T) { + t.Setenv("GO_LOOSE_BASE_URL", "http://go-loose.test") + handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler reached without authorization") + })) + + missingTenant := httptest.NewRecorder() + handler.ServeHTTP(missingTenant, httptest.NewRequest(http.MethodGet, "/api/jobs", nil)) + if missingTenant.Code != http.StatusBadRequest { + t.Fatalf("missing tenant status = %d", missingTenant.Code) + } + + missingKey := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + missingKey.Header.Set(client.XTenantId, "nmbs") + missingKeyResponse := httptest.NewRecorder() + handler.ServeHTTP(missingKeyResponse, missingKey) + if missingKeyResponse.Code != http.StatusUnauthorized { + t.Fatalf("missing key status = %d", missingKeyResponse.Code) + } +} + +func TestGoLooseMiddlewareRejectsUnsafeTenantInHost(t *testing.T) { + t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth.dev") + handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler reached for unsafe tenant") + })) + request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + request.Header.Set(client.XTenantId, "nmbs.evil.example") + request.Header.Set(client.DefaultHeader, "gl_test_key") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware.go b/backend/internal/web/middleware/TenantSchemaMiddleware.go index 3dc78e2..56b68a8 100644 --- a/backend/internal/web/middleware/TenantSchemaMiddleware.go +++ b/backend/internal/web/middleware/TenantSchemaMiddleware.go @@ -6,6 +6,7 @@ import ( "net/http" "regexp" + "github.com/danyel/go-loose/client" "gorm.io/gorm" ) @@ -24,7 +25,7 @@ func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } - tenantID := r.Header.Get("X-Tenant-Id") + tenantID := r.Header.Get(client.XTenantId) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) diff --git a/backend/internal/web/openapi/openapi.go b/backend/internal/web/openapi/openapi.go index e48f8d2..ad0f60c 100644 --- a/backend/internal/web/openapi/openapi.go +++ b/backend/internal/web/openapi/openapi.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + + "github.com/danyel/go-loose/client" ) type Operation struct { @@ -86,7 +88,10 @@ func (b *Builder) Add(method, path string, operation Operation) { "responses": responses, } if operation.Protected { - value["security"] = []map[string][]string{{"bearerAuth": {}}} + value["security"] = []map[string][]string{{ + "apiKeyAuth": {}, + "bearerAuth": {}, + }} } if operation.Request != nil { requestContentType := operation.RequestContentType @@ -126,6 +131,7 @@ func (b *Builder) Document() map[string]any { "paths": b.paths, "components": map[string]any{ "securitySchemes": map[string]any{ + "apiKeyAuth": map[string]any{"type": "apiKey", "in": "header", "name": client.DefaultHeader}, "bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, }, "schemas": b.schemas, @@ -137,7 +143,7 @@ func (b *Builder) parameters(path string, queries []string, tenantRequired bool) result := make([]map[string]any, 0) if tenantRequired { result = append(result, map[string]any{ - "name": "X-Tenant-Id", "in": "header", "required": true, + "name": client.XTenantId, "in": "header", "required": true, "schema": map[string]any{"type": "string"}, }) } diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 3533ca7..bc98870 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -43,6 +43,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents, openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"}) router.Group(func(protected chi.Router) { + protected.Use(mw.GoLooseMiddleware()) protected.Use(authenticate(tokens)) register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs, openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}}) @@ -161,7 +162,7 @@ func cors(origin string) func(http.Handler) http.Handler { if allowedCORSOrigin(requestOrigin, origin) { w.Header().Set("Access-Control-Allow-Origin", requestOrigin) } - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Tenant-Id") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Add("Vary", "Origin") if r.Method == http.MethodOptions { diff --git a/deploy/helm/go-guess/templates/NOTES.txt b/deploy/helm/go-guess/templates/NOTES.txt index 7389a46..eef12fe 100644 --- a/deploy/helm/go-guess/templates/NOTES.txt +++ b/deploy/helm/go-guess/templates/NOTES.txt @@ -1,4 +1,5 @@ Go Guess has been installed in namespace {{ .Release.Namespace }}. +Protected interviewer routes authorize with Go Loose at {{ .Values.app.goLooseBaseURL }}. {{- if .Values.ingress.enabled }} Open {{ .Values.app.frontendURL }} diff --git a/deploy/helm/go-guess/templates/app-deployment.yaml b/deploy/helm/go-guess/templates/app-deployment.yaml index d271706..c25caed 100644 --- a/deploy/helm/go-guess/templates/app-deployment.yaml +++ b/deploy/helm/go-guess/templates/app-deployment.yaml @@ -48,6 +48,8 @@ spec: value: {{ .Values.app.tokenTTL | quote }} - name: MAX_UPLOAD_MB value: {{ .Values.app.maxUploadMB | quote }} + - name: GO_LOOSE_BASE_URL + value: {{ .Values.app.goLooseBaseURL | quote }} - name: DATABASE_URL valueFrom: secretKeyRef: diff --git a/deploy/helm/go-guess/values.yaml b/deploy/helm/go-guess/values.yaml index 6537aa2..3dc2eb3 100644 --- a/deploy/helm/go-guess/values.yaml +++ b/deploy/helm/go-guess/values.yaml @@ -15,6 +15,8 @@ app: frontendURL: http://localhost:8080 tokenTTL: 8h maxUploadMB: 10 + # Authorize URL for Go Loose. %s is the request X-Tenant-Id. + goLooseBaseURL: https://%s.auth.dev secrets: jwtSecret: "" diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 46b59a2..23cfc4b 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -54,3 +54,9 @@ curl --fail https://goguess.urpi.be/api/health The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ services should remain `ClusterIP` services without NodePorts. + +Protected interviewer routes also call Go Loose from the application pod, using +`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the +request tenant). That address must be reachable from the cluster. The browser +sends `X-API-Key` only when the published frontend image was built with +`VITE_GO_LOOSE_API_KEY`. Nginx does not add or strip that header. diff --git a/docker-compose.yml b/docker-compose.yml index 1d058ae..e469c0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,13 +35,18 @@ services: environment: DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable JWT_SECRET: local-development-secret-change-me-12345 - FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} + FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local} APP_ENV: ${APP_ENV:-development} + GO_LOOSE_BASE_URL: ${GO_LOOSE_BASE_URL:-https://%s.auth.dev} + SSL_CERT_FILE: /certs/local-ca.crt RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ - ports: - - "${API_PORT:-8080}:8080" + expose: + - "8080" networks: - go_guess_network + - local_dev_edge + volumes: + - ${LOCAL_DEV_CERTS_DIR:-/home/dnoulet/go/infrasctruture/certs}/local-ca.crt:/certs/local-ca.crt:ro depends_on: db: condition: service_healthy @@ -49,15 +54,24 @@ services: condition: service_healthy web: - build: ./frontend - ports: - - "${WEB_PORT:-5173}:80" + build: + context: ./frontend + args: + VITE_GO_LOOSE_API_KEY: ${VITE_GO_LOOSE_API_KEY:-} + expose: + - "80" depends_on: - api networks: - - go_guess_network + go_guess_network: {} + local_dev_edge: + aliases: + - go-guess-web networks: go_guess_network: + local_dev_edge: + external: true + name: local-dev-edge volumes: postgres_data: postgres_data2: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 16970b8..fcefeea 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,6 +3,8 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . +ARG VITE_GO_LOOSE_API_KEY +ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY RUN npm run build FROM nginx:1.29-alpine diff --git a/frontend/README.md b/frontend/README.md index df57df8..6992669 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -37,10 +37,26 @@ application shell. ## API integration For local tenant hosts such as `nmbs.guess.local`, `src/api/client.ts` derives -tenant `nmbs`, calls `http://nmbs.guess.local:8080/api`, and sends +tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends `X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream request. `ypto.guess.local` behaves identically for tenant `ypto`. Other hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case. + +Set `VITE_GO_LOOSE_API_KEY` before `npm run dev` or `npm run build` to also send +`X-API-Key`. Vite reads `frontend/.env.local` for local development; that file +matches `*.local` and must not be committed. The standalone frontend image +accepts the same value as a build argument: + +```bash +docker build --build-arg VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' \ + -t go-guess-frontend . +``` + +The variable is inlined into the JavaScript bundle. Treat it as an application +credential for Go Loose, not as a secret equivalent to the interviewer JWT. If +it is unset or empty, the client omits the header and protected interviewer +routes fail closed with 401. Login and participant routes do not require the +key, but the client still sends it when the variable is present. Invitation and participant-meeting URLs use the validated request origin so links stay on the tenant hostname. Copy actions fall back to a temporary selected textarea when the Clipboard API is unavailable on local HTTP domains. @@ -104,5 +120,7 @@ receives interviewer notes or assessment reference answers. ## Container -Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and -proxies `/api` to the Docker Compose service `api:8080`. +Build with `docker build -t go-guess-frontend .`. Pass +`--build-arg VITE_GO_LOOSE_API_KEY=...` when the image must call protected +interviewer routes; see the API integration section. The production Nginx server +serves the SPA and proxies `/api` to the Docker Compose service `api:8080`. diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 8ad51e4..cb669a4 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -7,14 +7,12 @@ describe('API client', () => { sessionStorage.clear() }) - it('derives the local tenant API origin from the browser hostname', () => { + it('uses the same-origin API behind the local HTTPS proxy', () => { expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs') expect(tenantFromHostname('ypto.guess.local')).toBe('ypto') expect(tenantFromHostname('localhost')).toBeNull() expect(tenantFromHostname('127.0.0.1')).toBeNull() - expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'http:' })).toBe( - 'http://nmbs.guess.local:8080/api', - ) + expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'https:' })).toBe('/api') expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api') }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 32c809c..006830a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -82,7 +82,7 @@ export function tenantFromHostname(hostname: string): string | null { export function apiBaseURL(location: Pick): string { const hostname = location.hostname.toLowerCase().replace(/\.$/, '') if (hostname.endsWith('.guess.local')) { - return `${location.protocol}//${hostname}:8080/api` + return '/api' } return '/api' } @@ -93,7 +93,11 @@ function apiURL(path: string): string { function tenantHeader(): Record { const tenant = tenantFromHostname(window.location.hostname) - return tenant ? { 'X-Tenant-Id': tenant } : {} + const apiKey = import.meta.env.VITE_GO_LOOSE_API_KEY + return { + ...(tenant ? { 'X-Tenant-Id': tenant } : {}), + ...(typeof apiKey === 'string' && apiKey !== '' ? { 'X-API-Key': apiKey } : {}), + } } async function request(