changed deployment
This commit is contained in:
parent
ea4e057b02
commit
70396c321a
22 changed files with 391 additions and 52 deletions
19
.github/copilot-instructions.md
vendored
19
.github/copilot-instructions.md
vendored
|
|
@ -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
|
Keep feature-specific components inside their feature directory. Vite proxies
|
||||||
`/api` to the Go service in development.
|
`/api` to the Go service in development.
|
||||||
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
|
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
|
||||||
tenant from the hostname, calls `http://<tenant>.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
|
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
|
||||||
`/api`. Generated participant links must use the validated matching tenant
|
`/api`. Generated participant links must use the validated matching tenant
|
||||||
origin. Keep this contract documented in `README.md` and `frontend/README.md`.
|
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.
|
Goose files in `backend/migrations` are schema-only and run in every environment.
|
||||||
`internal/database/seed/common.sql` contains shared reference data;
|
`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
|
Every entity change requires a migration plus corresponding fixture and integration
|
||||||
test updates. Never put demo users or test records in schema migrations.
|
test updates. Never put demo users or test records in schema migrations.
|
||||||
|
|
||||||
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
|
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Interviewer
|
||||||
protected except `/api/health`, `/api/auth/login`, and opaque-token participant
|
routes additionally require a Go Loose API key for the request tenant. Health,
|
||||||
interview routes. Never expose password hashes, CV/photo bytes, or interviewer
|
OpenAPI, login, and opaque-token participant interview routes do not. Never
|
||||||
reference answers in public JSON.
|
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
|
Questions are permanent reusable records linked to jobs through
|
||||||
`job_posting_questions`; detach links rather than deleting questions. Deprecated
|
`job_posting_questions`; detach links rather than deleting questions. Deprecated
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ WORKDIR /src/frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
ARG VITE_GO_LOOSE_API_KEY
|
||||||
|
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Compile the API and seed binaries.
|
# Compile the API and seed binaries.
|
||||||
|
|
|
||||||
92
README.md
92
README.md
|
|
@ -24,16 +24,22 @@ Add the local tenant hostnames once:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
127.0.0.1 nmbs.guess.local ypto.guess.local
|
127.0.0.1 nmbs.guess.local ypto.guess.local
|
||||||
|
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://nmbs.guess.local:5173> or <http://ypto.guess.local:5173>. The
|
Start the shared TLS proxy described in
|
||||||
hostname's first label is the tenant ID: the frontend calls
|
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open
|
||||||
`http://<tenant>.guess.local:8080/api` and sends the same value in the
|
<https://nmbs.guess.local> or <https://ypto.guess.local>. 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
|
`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
|
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
|
||||||
builds participant invitation and meeting links from the matching tenant browser
|
builds participant invitation and meeting links from the matching tenant browser
|
||||||
origin instead of the static localhost fallback.
|
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:
|
Sign in with:
|
||||||
|
|
||||||
- Email: `interviewer@ypto.local`
|
- Email: `interviewer@ypto.local`
|
||||||
|
|
@ -49,23 +55,74 @@ make backend
|
||||||
make frontend
|
make frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
The API listens on port `8080`. Vite permits the documented local tenant
|
The API listens on port `8080` inside Docker and is exposed to browsers only
|
||||||
hostnames; the API CORS policy permits `*.guess.local` development origins and
|
through Traefik and the frontend's same-origin `/api` proxy. Vite permits the
|
||||||
the `X-Tenant-Id` request header. Non-local deployments keep using same-origin
|
documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and
|
||||||
`/api`. Set a different secure `JWT_SECRET` outside local development.
|
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
|
## API contract
|
||||||
|
|
||||||
The backend exposes its current OpenAPI 3.0 contract at
|
The backend exposes its current OpenAPI 3.0 contract at
|
||||||
<http://localhost:8080/api/openapi.json>. The document is assembled in memory
|
<https://nmbs.guess.local/api/openapi.json>. The document is assembled in memory
|
||||||
from the same typed registrations that add routes to Chi, including request and
|
from the same typed registrations that add routes to Chi, including request and
|
||||||
response schemas, status codes, authentication, tenant headers, path/query
|
response schemas, status codes, authentication, tenant headers, path/query
|
||||||
parameters, multipart uploads, downloads, and event streams. It therefore
|
parameters, multipart uploads, downloads, and event streams. It therefore
|
||||||
requires no generation command and cannot miss a newly registered route.
|
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
|
`/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
|
tenant header. Every other route declares required `X-Tenant-Id`. Protected
|
||||||
operations additionally declare JWT bearer authentication.
|
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
|
## Quality commands
|
||||||
|
|
||||||
|
|
@ -126,7 +183,8 @@ docker build -t go-guess .
|
||||||
|
|
||||||
The image serves the frontend and API on port `8080`. It applies database
|
The image serves the frontend and API on port `8080`. It applies database
|
||||||
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
|
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
|
```bash
|
||||||
docker run --rm -p 8080:8080 \
|
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 RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
|
||||||
-e JWT_SECRET='replace-with-at-least-32-characters' \
|
-e JWT_SECRET='replace-with-at-least-32-characters' \
|
||||||
-e FRONTEND_URL='https://go-guess.example.com' \
|
-e FRONTEND_URL='https://go-guess.example.com' \
|
||||||
|
-e GO_LOOSE_BASE_URL='https://%s.auth.dev' \
|
||||||
go-guess
|
go-guess
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -160,6 +219,15 @@ For the repository's single-node Docker Rancher setup, run
|
||||||
installs the default persistent StorageClass documented in
|
installs the default persistent StorageClass documented in
|
||||||
`rancher-install.txt`.
|
`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 `<tenant>.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,
|
Development uses demo fixtures, ephemeral database and message-broker storage,
|
||||||
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
||||||
generated secrets that are preserved across upgrades, and larger resource
|
generated secrets that are preserved across upgrades, and larger resource
|
||||||
|
|
@ -201,6 +269,8 @@ the registry in production when possible.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Requests enter the Chi router and web handlers under `backend/internal/web`.
|
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
|
Web request/response models are translated to service models before business logic
|
||||||
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
||||||
repository maps service models to GORM entities and persists them in PostgreSQL.
|
repository maps service models to GORM entities and persists them in PostgreSQL.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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
|
FROM golang:1.27.1-alpine AS tools
|
||||||
RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ module github.com/danyel/go-guess/backend
|
||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.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/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
github.com/rabbitmq/amqp091-go v1.15.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/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.uber.org/multierr v1.11.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/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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
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=
|
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/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 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/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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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/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 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/client"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"gorm.io/gorm"
|
"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 {
|
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")), "/")
|
origin := strings.TrimRight(strings.TrimSpace(r.Header.Get("Origin")), "/")
|
||||||
if tenant == "" || origin == "" {
|
if tenant == "" || origin == "" {
|
||||||
return h.frontendURL
|
return h.frontendURL
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ package handler
|
||||||
import (
|
import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFrontendURLForTenantRequest(t *testing.T) {
|
func TestFrontendURLForTenantRequest(t *testing.T) {
|
||||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
request.Header.Set("Origin", "http://ypto.guess.local:5173")
|
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" {
|
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
|
||||||
t.Fatalf("unexpected frontend URL %q", value)
|
t.Fatalf("unexpected frontend URL %q", value)
|
||||||
|
|
@ -20,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
||||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
|
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 {
|
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
|
||||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||||
|
|
|
||||||
99
backend/internal/web/middleware/GoLooseMiddleware.go
Normal file
99
backend/internal/web/middleware/GoLooseMiddleware.go
Normal file
|
|
@ -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})
|
||||||
|
}
|
||||||
97
backend/internal/web/middleware/GoLooseMiddleware_test.go
Normal file
97
backend/internal/web/middleware/GoLooseMiddleware_test.go
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/client"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenantID := r.Header.Get("X-Tenant-Id")
|
tenantID := r.Header.Get(client.XTenantId)
|
||||||
if tenantID == "" {
|
if tenantID == "" {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-loose/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Operation struct {
|
type Operation struct {
|
||||||
|
|
@ -86,7 +88,10 @@ func (b *Builder) Add(method, path string, operation Operation) {
|
||||||
"responses": responses,
|
"responses": responses,
|
||||||
}
|
}
|
||||||
if operation.Protected {
|
if operation.Protected {
|
||||||
value["security"] = []map[string][]string{{"bearerAuth": {}}}
|
value["security"] = []map[string][]string{{
|
||||||
|
"apiKeyAuth": {},
|
||||||
|
"bearerAuth": {},
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
if operation.Request != nil {
|
if operation.Request != nil {
|
||||||
requestContentType := operation.RequestContentType
|
requestContentType := operation.RequestContentType
|
||||||
|
|
@ -126,6 +131,7 @@ func (b *Builder) Document() map[string]any {
|
||||||
"paths": b.paths,
|
"paths": b.paths,
|
||||||
"components": map[string]any{
|
"components": map[string]any{
|
||||||
"securitySchemes": 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"},
|
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
|
||||||
},
|
},
|
||||||
"schemas": b.schemas,
|
"schemas": b.schemas,
|
||||||
|
|
@ -137,7 +143,7 @@ func (b *Builder) parameters(path string, queries []string, tenantRequired bool)
|
||||||
result := make([]map[string]any, 0)
|
result := make([]map[string]any, 0)
|
||||||
if tenantRequired {
|
if tenantRequired {
|
||||||
result = append(result, map[string]any{
|
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"},
|
"schema": map[string]any{"type": "string"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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"})
|
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
|
||||||
router.Group(func(protected chi.Router) {
|
router.Group(func(protected chi.Router) {
|
||||||
|
protected.Use(mw.GoLooseMiddleware())
|
||||||
protected.Use(authenticate(tokens))
|
protected.Use(authenticate(tokens))
|
||||||
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
|
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
|
||||||
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
|
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) {
|
if allowedCORSOrigin(requestOrigin, origin) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", requestOrigin)
|
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().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
w.Header().Add("Vary", "Origin")
|
w.Header().Add("Vary", "Origin")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
Go Guess has been installed in namespace {{ .Release.Namespace }}.
|
Go Guess has been installed in namespace {{ .Release.Namespace }}.
|
||||||
|
Protected interviewer routes authorize with Go Loose at {{ .Values.app.goLooseBaseURL }}.
|
||||||
|
|
||||||
{{- if .Values.ingress.enabled }}
|
{{- if .Values.ingress.enabled }}
|
||||||
Open {{ .Values.app.frontendURL }}
|
Open {{ .Values.app.frontendURL }}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ spec:
|
||||||
value: {{ .Values.app.tokenTTL | quote }}
|
value: {{ .Values.app.tokenTTL | quote }}
|
||||||
- name: MAX_UPLOAD_MB
|
- name: MAX_UPLOAD_MB
|
||||||
value: {{ .Values.app.maxUploadMB | quote }}
|
value: {{ .Values.app.maxUploadMB | quote }}
|
||||||
|
- name: GO_LOOSE_BASE_URL
|
||||||
|
value: {{ .Values.app.goLooseBaseURL | quote }}
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ app:
|
||||||
frontendURL: http://localhost:8080
|
frontendURL: http://localhost:8080
|
||||||
tokenTTL: 8h
|
tokenTTL: 8h
|
||||||
maxUploadMB: 10
|
maxUploadMB: 10
|
||||||
|
# Authorize URL for Go Loose. %s is the request X-Tenant-Id.
|
||||||
|
goLooseBaseURL: https://%s.auth.dev
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
jwtSecret: ""
|
jwtSecret: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
|
||||||
services should remain `ClusterIP` services without NodePorts.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,18 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
||||||
JWT_SECRET: local-development-secret-change-me-12345
|
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}
|
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/
|
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
||||||
ports:
|
expose:
|
||||||
- "${API_PORT:-8080}:8080"
|
- "8080"
|
||||||
networks:
|
networks:
|
||||||
- go_guess_network
|
- 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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -49,15 +54,24 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: ./frontend
|
build:
|
||||||
ports:
|
context: ./frontend
|
||||||
- "${WEB_PORT:-5173}:80"
|
args:
|
||||||
|
VITE_GO_LOOSE_API_KEY: ${VITE_GO_LOOSE_API_KEY:-}
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
networks:
|
networks:
|
||||||
- go_guess_network
|
go_guess_network: {}
|
||||||
|
local_dev_edge:
|
||||||
|
aliases:
|
||||||
|
- go-guess-web
|
||||||
networks:
|
networks:
|
||||||
go_guess_network:
|
go_guess_network:
|
||||||
|
local_dev_edge:
|
||||||
|
external: true
|
||||||
|
name: local-dev-edge
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
postgres_data2:
|
postgres_data2:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG VITE_GO_LOOSE_API_KEY
|
||||||
|
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
FROM nginx:1.29-alpine
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,26 @@ application shell.
|
||||||
## API integration
|
## API integration
|
||||||
|
|
||||||
For local tenant hosts such as `nmbs.guess.local`, `src/api/client.ts` derives
|
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
|
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
|
||||||
request. `ypto.guess.local` behaves identically for tenant `ypto`. Other
|
request. `ypto.guess.local` behaves identically for tenant `ypto`. Other
|
||||||
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
|
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
|
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
|
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.
|
textarea when the Clipboard API is unavailable on local HTTP domains.
|
||||||
|
|
@ -104,5 +120,7 @@ receives interviewer notes or assessment reference answers.
|
||||||
|
|
||||||
## Container
|
## Container
|
||||||
|
|
||||||
Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and
|
Build with `docker build -t go-guess-frontend .`. Pass
|
||||||
proxies `/api` to the Docker Compose service `api:8080`.
|
`--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`.
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,12 @@ describe('API client', () => {
|
||||||
sessionStorage.clear()
|
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('nmbs.guess.local')).toBe('nmbs')
|
||||||
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
|
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
|
||||||
expect(tenantFromHostname('localhost')).toBeNull()
|
expect(tenantFromHostname('localhost')).toBeNull()
|
||||||
expect(tenantFromHostname('127.0.0.1')).toBeNull()
|
expect(tenantFromHostname('127.0.0.1')).toBeNull()
|
||||||
expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'http:' })).toBe(
|
expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'https:' })).toBe('/api')
|
||||||
'http://nmbs.guess.local:8080/api',
|
|
||||||
)
|
|
||||||
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
|
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function tenantFromHostname(hostname: string): string | null {
|
||||||
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
|
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
|
||||||
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
|
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
|
||||||
if (hostname.endsWith('.guess.local')) {
|
if (hostname.endsWith('.guess.local')) {
|
||||||
return `${location.protocol}//${hostname}:8080/api`
|
return '/api'
|
||||||
}
|
}
|
||||||
return '/api'
|
return '/api'
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +93,11 @@ function apiURL(path: string): string {
|
||||||
|
|
||||||
function tenantHeader(): Record<string, string> {
|
function tenantHeader(): Record<string, string> {
|
||||||
const tenant = tenantFromHostname(window.location.hostname)
|
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<T>(
|
async function request<T>(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue