Skip to content

Commit d36b84e

Browse files
committed
feat(sparql-proxy): reader credential + configurable read auth
Split the Caddyfile's single basic_auth block into two role-based blocks so deployments can issue a read-only credential separately from the admin/write credential. - Writes (POST/PUT/DELETE/PATCH on /update and /store) always require Basic Auth, accepting only credentials from `users/admin/*`. This was the previous behavior, now isolated to its own block. - Reads can optionally be gated. The new `@reads_protected` matcher reads its path list from the `SPARQL_READ_AUTH_PATHS` env var on the sparql-proxy container. The default (`__off__`) is a sentinel that matches no real request, so reads stay public until an operator opts in by setting e.g. `SPARQL_READ_AUTH_PATHS=/query /query/*` in `.env`. When the matcher fires, it accepts credentials from EITHER `users/admin/*` or the new `users/reader/*` — admin keeps full access, reader can issue read-only access to clients. Env / config plumbing: - compose: sparql-proxy gets an `environment:` block that forwards `SPARQL_READ_AUTH_PATHS` (default `__off__`) into the container. - `infra/.env.example`: documents the optional new vars `SPARQL_READER_AUTH=reader/<password>` and `SPARQL_READ_AUTH_PATHS=/query /query/*`. - `scripts/fix-data-perms.sh`: now writes `users/admin/sparql_users.caddy` from `SPARQL_AUTH` and (if set) `users/reader/sparql_reader.caddy` from `SPARQL_READER_AUTH`. Migrates the legacy single-file layout (`users/sparql_users.caddy`) into `users/admin/` automatically. Backward-compat: - Deployments that don't set `SPARQL_READER_AUTH` or `SPARQL_READ_AUTH_PATHS` behave identically to before — anonymous reads, admin-only writes. - The legacy users-file path migrates in place when `fix-data-perms.sh` runs (which the deploy procedure now invokes as standard).
1 parent 4e82739 commit d36b84e

4 files changed

Lines changed: 122 additions & 44 deletions

File tree

infra/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ SPARQL_PROXY_PORT=7878
121121
# infra/services/sparql-proxy/users (bcrypt hashes).
122122
SPARQL_AUTH=openpulse/replace-me
123123

124+
# Optional reader credential for SPARQL queries. Setting this alone does
125+
# NOT gate reads — you also need to set SPARQL_READ_AUTH_PATHS below to
126+
# the read paths you want to protect (e.g. `/query /query/*`). Admin
127+
# credentials (SPARQL_AUTH) always continue to work on those paths too.
128+
# SPARQL_READER_AUTH=reader/replace-me
129+
# SPARQL_READ_AUTH_PATHS=/query /query/*
130+
124131

125132
# ── Portainer (--profile orchestration) ───────────────────────────────────
126133
# 7508 is reserved by the GrimoireLab nginx reverse-proxy (Kibiter UI). Use

infra/open-pulse-stack/docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ services:
281281
condition: service_started
282282
ports:
283283
- "${SPARQL_PROXY_PORT:-7878}:7878"
284+
environment:
285+
# Paths to protect with the reader/admin Basic Auth block in the
286+
# Caddyfile. Default `__off__` is a sentinel that matches no real
287+
# request, so reads pass through anonymously. Set to e.g.
288+
# `/query /query/*` in your .env to require auth on reads (admin
289+
# OR reader credentials accepted).
290+
SPARQL_READ_AUTH_PATHS: "${SPARQL_READ_AUTH_PATHS:-__off__}"
284291
volumes:
285292
- ../services/sparql-proxy/Caddyfile:/etc/caddy/Caddyfile:ro
286293
- ../services/sparql-proxy/users:/etc/caddy/users:ro

infra/services/sparql-proxy/Caddyfile

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Caddyfile for the SPARQL reverse proxy.
22
#
3-
# Public read on /query and /. Authenticated writes on /update and /store
4-
# (the SPARQL Update + Graph Store HTTP Protocol endpoints).
3+
# Always-protected writes on /update and /store (admin credential).
4+
# Reads (`/query` and `/`) are public BY DEFAULT but can be gated behind a
5+
# second credential — see SPARQL_READ_AUTH_PATHS below.
56
#
6-
# Users come from /etc/caddy/users (mounted from infra/services/sparql-proxy/users
7-
# in the host). One user per line: `user <bcrypt-hash>`.
7+
# Users come from /etc/caddy/users/{admin,reader}/* (mounted from
8+
# infra/services/sparql-proxy/users in the host). One user per line:
9+
# `user <bcrypt-hash>`. The hashes are generated by
10+
# scripts/fix-data-perms.sh from SPARQL_AUTH and SPARQL_READER_AUTH.
811
#
9-
# The admin API is enabled on the default localhost:2019 inside the container only
10-
# (not exposed to the host). This is what powers `caddy reload` for hot-reloading
11-
# users without restarting the container.
12+
# The admin API is enabled on the default localhost:2019 inside the
13+
# container only (not exposed to the host). That's what powers
14+
# `caddy reload` for hot-reloading users without restarting the container.
1215

1316
{
1417
auto_https off
@@ -28,19 +31,39 @@
2831
}
2932

3033
# ── SPARQL reverse-proxy ──────────────────────────────────────────
31-
# Match every request that mutates the store.
34+
35+
# Optional read auth.
36+
#
37+
# When SPARQL_READ_AUTH_PATHS is unset, Caddy substitutes the sentinel
38+
# `__off__`, which matches no real request → reads pass through.
39+
#
40+
# Set SPARQL_READ_AUTH_PATHS (in the sparql-proxy service env) to one or
41+
# more paths to gate reads behind auth. Example:
42+
#
43+
# SPARQL_READ_AUTH_PATHS=/query /query/*
44+
#
45+
# Both `users/admin/*` and `users/reader/*` are imported here, so admin
46+
# credentials are accepted on reads (in addition to writes), while a
47+
# dedicated reader credential can be issued for read-only access.
48+
@reads_protected {
49+
path {$SPARQL_READ_AUTH_PATHS:__off__}
50+
}
51+
basic_auth @reads_protected {
52+
import /etc/caddy/users/admin/*
53+
import /etc/caddy/users/reader/*
54+
}
55+
56+
# Always-protected write paths. Admin credential only.
3257
@writes {
3358
method POST PUT DELETE PATCH
3459
path /update /update/* /store /store/*
3560
}
36-
37-
# Require Basic Auth on writes only. Reads pass through.
3861
basic_auth @writes {
39-
import /etc/caddy/users/*
62+
import /etc/caddy/users/admin/*
4063
}
4164

42-
# Everything (matched or not) is forwarded to Oxigraph. The matcher above
43-
# determines whether basic_auth runs first.
65+
# Everything (matched or not) is forwarded to Oxigraph. The matchers
66+
# above determine whether basic_auth runs first.
4467
reverse_proxy oxigraph:7878 {
4568
header_up Host {host}
4669
}

scripts/fix-data-perms.sh

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -127,53 +127,94 @@ restart_if_running() {
127127
# Phase 1 — sparql-proxy missing users file
128128
###############################################################################
129129

130+
# Generate one Caddyfile basic_auth users file from a `user/password` env value.
131+
# Writes <users_dir>/<file>; chmod 0644. No-op on dry-run.
132+
_write_caddy_user_file() {
133+
local sp_value="$1" users_dir="$2" filename="$3" role="$4"
134+
if [[ -z "$sp_value" || "$sp_value" != */* ]]; then
135+
printf ' %s %s: no value (or malformed) — skipping\n' "$(yellow '[skip]')" "$role"
136+
return
137+
fi
138+
local user pass
139+
user=${sp_value%%/*}
140+
pass=${sp_value#*/}
141+
if [[ -z "$user" || -z "$pass" ]]; then
142+
printf ' %s %s: empty user or password — skipping\n' "$(yellow '[skip]')" "$role"
143+
return
144+
fi
145+
mkdir -p "$users_dir"
146+
local out="$users_dir/$filename"
147+
if [[ "$DRY_RUN" -eq 1 ]]; then
148+
printf ' %s would write %s (user %s)\n' "$(yellow '[dry-run]')" "$out" "$user"
149+
return
150+
fi
151+
local hash
152+
hash=$(docker run --rm caddy:2-alpine caddy hash-password --plaintext "$pass" 2>/dev/null)
153+
if [[ -z "$hash" ]]; then
154+
printf ' %s %s: caddy hash-password failed\n' "$(red '[fail]')" "$role"
155+
return
156+
fi
157+
printf '%s %s\n' "$user" "$hash" > "$out"
158+
chmod 0644 "$out"
159+
printf ' %s wrote %s for user %s\n' "$(green '[ok]')" "$out" "$user"
160+
}
161+
130162
fix_sparql_users() {
131-
log "sparql-proxy: ensure Caddy basic_auth users file"
163+
log "sparql-proxy: ensure Caddy basic_auth users files (admin + optional reader)"
132164
local sp_dir="$INFRA/services/sparql-proxy"
133165
local users_dir="$sp_dir/users"
166+
local admin_dir="$users_dir/admin"
167+
local reader_dir="$users_dir/reader"
134168
local caddy="$sp_dir/Caddyfile"
135-
local users_file="$users_dir/sparql_users.caddy"
136169
local env_file="$INFRA/env/.env"
137170

138-
mkdir -p "$users_dir"
139-
if compgen -G "$users_dir/*" >/dev/null; then
140-
printf ' %s users file already present in %s\n' "$(green '[ok]')" "$users_dir"
141-
else
142-
if [[ ! -f "$env_file" ]]; then
143-
printf ' %s no %s; skipping users file generation\n' "$(yellow '[skip]')" "$env_file"
144-
return
171+
mkdir -p "$admin_dir" "$reader_dir"
172+
173+
# Migrate legacy single-file layout if present.
174+
local legacy="$users_dir/sparql_users.caddy"
175+
if [[ -f "$legacy" && ! -f "$admin_dir/sparql_users.caddy" ]]; then
176+
if [[ "$DRY_RUN" -eq 1 ]]; then
177+
printf ' %s would migrate %s -> %s\n' "$(yellow '[dry-run]')" "$legacy" "$admin_dir/sparql_users.caddy"
178+
else
179+
mv "$legacy" "$admin_dir/sparql_users.caddy"
180+
printf ' %s migrated legacy users file to %s\n' "$(green '[ok]')" "$admin_dir/sparql_users.caddy"
145181
fi
146-
local sparql_auth user pass
182+
fi
183+
184+
if [[ ! -f "$env_file" ]]; then
185+
printf ' %s no %s; skipping users file generation\n' "$(yellow '[skip]')" "$env_file"
186+
else
187+
# Admin (writes, plus reads if SPARQL_READ_AUTH_PATHS is set).
188+
local sparql_auth
147189
sparql_auth=$(grep -E '^SPARQL_AUTH=' "$env_file" | head -1 | sed 's/^SPARQL_AUTH=//')
148-
user=${sparql_auth%%/*}
149-
pass=${sparql_auth#*/}
150-
if [[ -z "$user" || -z "$pass" || "$user" == "$pass" ]]; then
151-
printf ' %s SPARQL_AUTH missing or malformed in %s\n' "$(red '[fail]')" "$env_file"
152-
return
153-
fi
154-
if [[ "$DRY_RUN" -eq 1 ]]; then
155-
printf ' %s would generate %s for user %s\n' "$(yellow '[dry-run]')" "$users_file" "$user"
156-
return
190+
if compgen -G "$admin_dir/*" >/dev/null && [[ "$DRY_RUN" -ne 1 ]]; then
191+
printf ' %s admin users file already present in %s\n' "$(green '[ok]')" "$admin_dir"
192+
else
193+
_write_caddy_user_file "$sparql_auth" "$admin_dir" "sparql_users.caddy" "admin"
157194
fi
158-
local hash
159-
hash=$(docker run --rm caddy:2-alpine caddy hash-password --plaintext "$pass" 2>/dev/null)
160-
if [[ -z "$hash" ]]; then
161-
printf ' %s caddy hash-password failed\n' "$(red '[fail]')"
162-
return
195+
196+
# Reader (optional). Generated only if SPARQL_READER_AUTH is set.
197+
local reader_auth
198+
reader_auth=$(grep -E '^SPARQL_READER_AUTH=' "$env_file" | head -1 | sed 's/^SPARQL_READER_AUTH=//')
199+
if [[ -n "$reader_auth" ]]; then
200+
if compgen -G "$reader_dir/*" >/dev/null && [[ "$DRY_RUN" -ne 1 ]]; then
201+
printf ' %s reader users file already present in %s\n' "$(green '[ok]')" "$reader_dir"
202+
else
203+
_write_caddy_user_file "$reader_auth" "$reader_dir" "sparql_reader.caddy" "reader"
204+
fi
205+
else
206+
printf ' %s SPARQL_READER_AUTH not set — read gate stays off\n' "$(gray '[note]')"
163207
fi
164-
printf '%s %s\n' "$user" "$hash" > "$users_file"
165-
chmod 0644 "$users_file"
166-
printf ' %s wrote %s\n' "$(green '[ok]')" "$users_file"
167208
fi
168209

169-
# Fix Caddyfile if it still imports the directory directly.
210+
# Legacy Caddyfile glob fix (idempotent — no-op once already patched).
170211
if grep -qE '^[[:space:]]*import /etc/caddy/users[[:space:]]*$' "$caddy"; then
171212
if [[ "$DRY_RUN" -eq 1 ]]; then
172-
printf ' %s would change "import /etc/caddy/users" -> "import /etc/caddy/users/*"\n' "$(yellow '[dry-run]')"
213+
printf ' %s would change "import /etc/caddy/users" -> "import /etc/caddy/users/admin/*"\n' "$(yellow '[dry-run]')"
173214
else
174215
cp -a "$caddy" "${caddy}.bak.$(date +%Y%m%d-%H%M%S)"
175-
sed -i -E 's|^([[:space:]]*)import /etc/caddy/users[[:space:]]*$|\1import /etc/caddy/users/*|' "$caddy"
176-
printf ' %s patched Caddyfile import to glob\n' "$(green '[ok]')"
216+
sed -i -E 's|^([[:space:]]*)import /etc/caddy/users[[:space:]]*$|\1import /etc/caddy/users/admin/*|' "$caddy"
217+
printf ' %s patched Caddyfile legacy import\n' "$(green '[ok]')"
177218
fi
178219
fi
179220

0 commit comments

Comments
 (0)