mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
feat(docker): enforce sidecar and AI deployment policy (Part 5) (#2817)
* feat(docker): enforce sidecar and AI deployment policy (part 5) * style(docker): apply policy Python formatting
This commit is contained in:
1 parent
9b25c5be50
commit
9dc4f0bdfb
9 files changed
+386
-36
No files matched your search
+4
-1
@@ -171,12 +171,15 @@ RUN mkdir -p /data
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf.template
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY docker/deployment_policy.py /usr/local/lib/geolibre/deployment_policy.py
|
||||
COPY docker/sidecar_policy.py /usr/local/lib/geolibre/sidecar_policy.py
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||
# Default auth snippet (disabled). entrypoint.sh rewrites it at start based
|
||||
# on GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD; baking a valid default keeps
|
||||
# `nginx -t` and non-entrypoint invocations working.
|
||||
&& printf '# Basic Auth disabled (GEOLIBRE_AUTH_USER/GEOLIBRE_AUTH_PASSWORD not set).\n' > /etc/nginx/geolibre-auth.conf \
|
||||
&& printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > /etc/nginx/geolibre-ai-proxy.conf
|
||||
&& printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > /etc/nginx/geolibre-ai-proxy.conf \
|
||||
&& printf '# Sidecar guards generated at boot.\n' > /etc/nginx/geolibre-sidecar-guards.conf \
|
||||
&& printf '# Sidecar off switch generated at boot.\n' > /etc/nginx/geolibre-sidecar-off.conf
|
||||
COPY --from=build /app/apps/geolibre-desktop/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Every backend route prefix must have an explicit container policy owner."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from geolibre_server.app.main import app
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"sidecar_policy", ROOT / "docker" / "sidecar_policy.py"
|
||||
)
|
||||
sp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(sp)
|
||||
|
||||
|
||||
def route_paths(routes, prefix=""):
|
||||
for route in routes:
|
||||
if hasattr(route, "path"):
|
||||
yield prefix + route.path
|
||||
else:
|
||||
# Recent FastAPI versions retain lazy include_router wrappers.
|
||||
# Inspect all routes, including those excluded from OpenAPI.
|
||||
yield from route_paths(
|
||||
route.original_router.routes, prefix + route.include_context.prefix
|
||||
)
|
||||
|
||||
|
||||
def test_every_sidecar_route_prefix_is_classified():
|
||||
prefixes = {path.lstrip("/").split("/", 1)[0] for path in route_paths(app.routes)}
|
||||
guarded = set(sp.ROUTE_CAPABILITIES)
|
||||
unguarded = set(sp.UNGUARDED_PREFIXES)
|
||||
assert not guarded & unguarded, "A prefix cannot be both guarded and exempt"
|
||||
assert prefixes == guarded | unguarded, (
|
||||
f"Unclassified backend prefixes: {prefixes - guarded - unguarded}; "
|
||||
f"policy prefixes without routes: {(guarded | unguarded) - prefixes}"
|
||||
)
|
||||
+24
-4
@@ -30,12 +30,21 @@ esac
|
||||
# policy. See docs/deployment-policy.md.
|
||||
python /usr/local/lib/geolibre/deployment_policy.py /usr/share/nginx/html/deployment.json
|
||||
|
||||
# Generate capability guards from the final validated policy, not raw env.
|
||||
SIDECAR_DECISION=$(python /usr/local/lib/geolibre/sidecar_policy.py /usr/share/nginx/html/deployment.json)
|
||||
case "$SIDECAR_DECISION" in
|
||||
SIDECAR_START=0) SIDECAR_START=0 ;;
|
||||
SIDECAR_START=1) SIDECAR_START=1 ;;
|
||||
*) echo "ERROR: Invalid sidecar policy decision." >&2; exit 1 ;;
|
||||
esac
|
||||
AI_ENABLED=$(python -c 'import json; print(int(json.load(open("/usr/share/nginx/html/deployment.json")).get("ai", {}).get("enabled") is True))')
|
||||
|
||||
# Optional AI proxy. All three values are required together so merely setting a
|
||||
# model never embeds or enables ai.geolibre.app. GEOLIBRE_AI_URL is deliberately
|
||||
# restricted to the same-origin /ai route; the remote Worker URL and instance
|
||||
# token remain server-side in nginx.
|
||||
AI_PROXY_CONF=/etc/nginx/geolibre-ai-proxy.conf
|
||||
if [ -n "${GEOLIBRE_AI_URL:-}" ] || [ -n "${GEOLIBRE_AI_PROXY_URL:-}" ] || [ -n "${GEOLIBRE_AI_PROXY_TOKEN:-}" ]; then
|
||||
if [ "$AI_ENABLED" = "1" ]; then
|
||||
if [ -z "${GEOLIBRE_AI_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_URL:-}" ] || [ -z "${GEOLIBRE_AI_PROXY_TOKEN:-}" ]; then
|
||||
echo "ERROR: GEOLIBRE_AI_URL, GEOLIBRE_AI_PROXY_URL, and GEOLIBRE_AI_PROXY_TOKEN must be set together." >&2
|
||||
exit 1
|
||||
@@ -105,7 +114,7 @@ if real_ip:
|
||||
|
||||
token = os.environ["GEOLIBRE_AI_PROXY_TOKEN"]
|
||||
config = f"""
|
||||
location /ai/ {{
|
||||
location ^~ /ai/ {{
|
||||
{real_ip}
|
||||
proxy_pass {upstream}/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -127,7 +136,13 @@ open("/etc/nginx/geolibre-ai-proxy.conf", "w").write(config)
|
||||
chmod 640 "$AI_PROXY_CONF"
|
||||
echo "Authenticated AI proxy enabled at /ai."
|
||||
else
|
||||
printf '# AI proxy disabled (GEOLIBRE_AI_URL not set).\n' > "$AI_PROXY_CONF"
|
||||
cat > "$AI_PROXY_CONF" <<'EOF'
|
||||
location ^~ /ai {
|
||||
types {}
|
||||
default_type application/json;
|
||||
return 403 '{"detail":"AI disabled by deployment policy"}';
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Runtime config the app reads at load (index.html pulls it in before the
|
||||
@@ -578,9 +593,14 @@ else
|
||||
rm -f "$HTPASSWD"
|
||||
fi
|
||||
|
||||
if [ "${GEOLIBRE_DISABLE_SIDECAR:-0}" != "1" ]; then
|
||||
if [ "${GEOLIBRE_DISABLE_SIDECAR:-0}" = "1" ]; then
|
||||
echo "Sidecar disabled: GEOLIBRE_DISABLE_SIDECAR=1."
|
||||
elif [ "$SIDECAR_START" = "1" ]; then
|
||||
echo "Sidecar starting: deployment capabilities permit sidecar routes."
|
||||
python -m uvicorn geolibre_server.app.main:app \
|
||||
--host 127.0.0.1 --port 8765 &
|
||||
else
|
||||
echo "Sidecar disabled: neither processing:run nor data:add is granted."
|
||||
fi
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
+9
-5
@@ -42,10 +42,8 @@ server {
|
||||
# TLS (a reverse proxy in front) on anything but a trusted network.
|
||||
include /etc/nginx/geolibre-auth.conf;
|
||||
|
||||
# Optional authenticated AI reverse proxy. entrypoint.sh writes a location
|
||||
# block here only when GEOLIBRE_AI_URL, GEOLIBRE_AI_PROXY_URL, and
|
||||
# GEOLIBRE_AI_PROXY_TOKEN are all configured. The browser calls the
|
||||
# same-origin /ai route; nginx injects the server-only instance token.
|
||||
# Optional authenticated AI reverse proxy, enabled only by the final
|
||||
# deployment policy. URL/token remain private container configuration.
|
||||
include /etc/nginx/geolibre-ai-proxy.conf;
|
||||
|
||||
# Unauthenticated health endpoint for the container HEALTHCHECK (and any
|
||||
@@ -64,10 +62,16 @@ server {
|
||||
gzip_types text/css application/javascript application/json application/wasm image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# More-specific normalized prefixes win over the proxy below; ^~ prevents
|
||||
# static-asset regexes from bypassing either the guards or the proxy.
|
||||
merge_slashes on;
|
||||
include /etc/nginx/geolibre-sidecar-guards.conf;
|
||||
|
||||
# Reverse-proxy the optional Python sidecar bundled in the same image. The
|
||||
# trailing slash strips the /sidecar/ prefix, so /sidecar/conversion/status
|
||||
# reaches the sidecar at /conversion/status. Being same-origin avoids CORS.
|
||||
location /sidecar/ {
|
||||
location ^~ /sidecar/ {
|
||||
include /etc/nginx/geolibre-sidecar-off.conf;
|
||||
proxy_pass http://127.0.0.1:8765/;
|
||||
proxy_http_version 1.1;
|
||||
# The sidecar's TrustedHostMiddleware only accepts loopback hosts, so
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Container-only capability enforcement for the normalized sidecar URI."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Any one owning capability grants the entire route prefix. Conversion is also
|
||||
# polled by raster jobs and used when adding data, so both owners must work.
|
||||
ROUTE_CAPABILITIES = {
|
||||
"whitebox": frozenset({"processing:run"}),
|
||||
"raster": frozenset({"processing:run"}),
|
||||
"vector": frozenset({"processing:run"}),
|
||||
"pointcloud": frozenset({"processing:run"}),
|
||||
"ml": frozenset({"processing:run"}),
|
||||
"sql": frozenset({"processing:run"}),
|
||||
"postgis": frozenset({"data:add"}),
|
||||
"conversion": frozenset({"processing:run", "data:add"}),
|
||||
}
|
||||
|
||||
# Explicit exceptions checked against FastAPI's routes by the backend suite.
|
||||
# These retain their existing behavior whenever the sidecar is running.
|
||||
UNGUARDED_PREFIXES = frozenset(
|
||||
{
|
||||
"health",
|
||||
"algorithms",
|
||||
"shutdown",
|
||||
"run",
|
||||
"docs",
|
||||
"redoc",
|
||||
"openapi.json",
|
||||
}
|
||||
)
|
||||
|
||||
_DENY = (
|
||||
" default_type application/json;\n"
|
||||
' return 403 \'{"detail":"Sidecar disabled by deployment policy"}\';\n'
|
||||
" types {}\n"
|
||||
)
|
||||
|
||||
|
||||
def generate_snippets(policy, disabled=False):
|
||||
"""Return (prefix guards, whole-sidecar off directives, should start)."""
|
||||
capabilities = policy.get("capabilities")
|
||||
# Omitted means unrestricted; [] is an explicit empty grant.
|
||||
granted = (
|
||||
frozenset().union(*ROUTE_CAPABILITIES.values())
|
||||
if capabilities is None
|
||||
else frozenset(capabilities)
|
||||
)
|
||||
start = not disabled and any(granted & required for required in ROUTE_CAPABILITIES.values())
|
||||
guards = "# Generated sidecar capability guards.\n"
|
||||
for prefix, required in ROUTE_CAPABILITIES.items():
|
||||
if not granted & required:
|
||||
# nginx matches its normalized URI (percent decoding and slash
|
||||
# merging), not $request_uri. ^~ also defeats static-asset regexes.
|
||||
guards += f"location ^~ /sidecar/{prefix} {{\n{_DENY}}}\n"
|
||||
off = "# Generated whole-sidecar switch.\n" + ("" if start else _DENY)
|
||||
return guards, off, start
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
print("usage: sidecar_policy.py <deployment-policy-path>", file=sys.stderr)
|
||||
return 2
|
||||
with open(argv[1], encoding="utf-8") as source:
|
||||
policy = json.load(source)
|
||||
guards, off, start = generate_snippets(
|
||||
policy, disabled=os.environ.get("GEOLIBRE_DISABLE_SIDECAR") == "1"
|
||||
)
|
||||
directory = Path("/etc/nginx")
|
||||
(directory / "geolibre-sidecar-guards.conf").write_text(guards, encoding="utf-8")
|
||||
(directory / "geolibre-sidecar-off.conf").write_text(off, encoding="utf-8")
|
||||
print(f"SIDECAR_START={int(start)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Unit coverage for container sidecar capability enforcement."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"sidecar_policy", ROOT / "docker" / "sidecar_policy.py"
|
||||
)
|
||||
sp = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(sp)
|
||||
|
||||
PROCESSING = {"whitebox", "raster", "vector", "pointcloud", "ml", "sql"}
|
||||
|
||||
|
||||
def test_route_ownership_contract():
|
||||
assert sp.ROUTE_CAPABILITIES == {
|
||||
**{prefix: frozenset({"processing:run"}) for prefix in PROCESSING},
|
||||
"postgis": frozenset({"data:add"}),
|
||||
"conversion": frozenset({"processing:run", "data:add"}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"capabilities,denied,start",
|
||||
[
|
||||
(None, set(), True),
|
||||
([], PROCESSING | {"postgis", "conversion"}, False),
|
||||
(["export:data"], PROCESSING | {"postgis", "conversion"}, False),
|
||||
(["data:add"], PROCESSING, True),
|
||||
(["processing:run"], {"postgis"}, True),
|
||||
(["processing:run", "data:add"], set(), True),
|
||||
(["project:edit", "sharing:embed"], PROCESSING | {"postgis", "conversion"}, False),
|
||||
],
|
||||
)
|
||||
def test_capability_matrix(capabilities, denied, start):
|
||||
policy = {"version": 1}
|
||||
if capabilities is not None:
|
||||
policy["capabilities"] = capabilities
|
||||
guards, off, should_start = sp.generate_snippets(policy)
|
||||
assert should_start is start
|
||||
for prefix in sp.ROUTE_CAPABILITIES:
|
||||
assert (f"location ^~ /sidecar/{prefix} {{" in guards) == (prefix in denied)
|
||||
assert guards.count("return 403") == len(denied)
|
||||
assert ("return 403" in off) == (not start)
|
||||
for snippet in (guards, off):
|
||||
if "return 403" in snippet:
|
||||
assert "default_type application/json;" in snippet
|
||||
assert '"detail":' in snippet
|
||||
for prefix in sp.UNGUARDED_PREFIXES:
|
||||
assert f"location ^~ /sidecar/{prefix} {{" not in guards
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"policy", [{"version": 1}, {"version": 1, "capabilities": ["processing:run", "data:add"]}]
|
||||
)
|
||||
def test_disable_override_turns_unrestricted_sidecar_off(policy):
|
||||
_, off, start = sp.generate_snippets(policy, disabled=True)
|
||||
assert start is False
|
||||
assert "return 403" in off
|
||||
assert "default_type application/json;" in off
|
||||
@@ -7,18 +7,23 @@ Capabilities are coarse on purpose. Each one names a whole class of action
|
||||
("may add data at all"), not an individual menu item, so a locked-down
|
||||
deployment cannot be defeated by one item somebody forgot to list.
|
||||
|
||||
!!! warning "This is a client-side gate, not an authorization boundary"
|
||||
!!! warning "Client gates are not a general authorization boundary"
|
||||
Withholding a capability removes the affordance: the menu is not rendered,
|
||||
the command palette does not list or run the action, the keyboard shortcut
|
||||
does nothing, and the embed API refuses the command. It does **not** stop
|
||||
someone with browser devtools, and it does not restrict the server.
|
||||
does nothing, and the embed API refuses the command. These client gates do
|
||||
**not** stop someone with browser devtools.
|
||||
|
||||
The sidecar (`/sidecar`) and AI proxy (`/ai`) endpoints answer the same
|
||||
requests whatever capabilities are configured. For a deployment that must
|
||||
hold up against its own users, keep the server-side protections in
|
||||
[Self-Hosting](self-hosting.md) — Basic Auth or a real auth proxy,
|
||||
`GEOLIBRE_CONVERSION_ROOTS`, `GEOLIBRE_DISABLE_SIDECAR` — and treat
|
||||
capabilities as the interface half of the story.
|
||||
The Docker container also enforces its final deployment policy at nginx:
|
||||
selected `/sidecar/` route families require `processing:run` or `data:add`,
|
||||
and `/ai` is disabled unless the final policy enables AI and the approved
|
||||
proxy environment is configured. See the exact route table and startup
|
||||
rules in [Self-Hosting](self-hosting.md#container-policy-enforcement).
|
||||
This enforcement is **container-only**: it does not restrict browser WASM
|
||||
engines, desktop processing, or a separately hosted sidecar.
|
||||
|
||||
Keep Basic Auth or a real auth proxy and `GEOLIBRE_CONVERSION_ROOTS` in
|
||||
place. Container policy enforcement is not user authentication, and
|
||||
capabilities remain the interface half of the story outside those routes.
|
||||
|
||||
## Not the same as UI Profiles
|
||||
|
||||
|
||||
+49
-3
@@ -149,14 +149,15 @@ Settings that matter for a private deployment:
|
||||
| `GEOLIBRE_AUTH0_DOMAIN` / `GEOLIBRE_AUTH0_CLIENT_ID` | unset, or both, if you use Auth0 instead of Clerk | The same sign-in gate backed by Auth0 Universal Login. Both are required together, and the container refuses to start if Clerk is configured as well — pick one provider. |
|
||||
| `GEOLIBRE_CONVERSION_ROOTS` | `/data` (the image default) | Confines every sidecar read and write to the mounted directory. |
|
||||
| `GEOLIBRE_POSTGIS_HOSTS` | unset unless needed | The sidecar's PostGIS endpoints refuse every destination until this names the allowed databases, so a caller cannot aim them at hosts only the container can reach. |
|
||||
| `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Runs nginx only. |
|
||||
| `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Skips uvicorn regardless of capabilities; every `/sidecar/` request returns HTTP 403 with an `application/json` detail response. |
|
||||
| `GEOLIBRE_EMBED_ORIGINS` | unset, or the exact host page origin | Off by default, so a framed deployment cannot be driven by whoever frames it. |
|
||||
| `GEOLIBRE_NO_EXTERNAL_CDN` (build arg) | `1` for restricted deployments | Strips GeoLibre's own references to external CDNs (`unpkg.com`, `cdn.jsdelivr.net`) from the build output. Features whose assets are only available from a CDN are disabled or degraded: storymap HTML export, built-in object detection models, ONNX WASM, 3D Tiles Draco/KTX2 decoders, and gdal3.js export. Pyodide is not hard-disabled — the flag drops only its default index URL, so setting `VITE_PYODIDE_INDEX_URL` to an approved mirror keeps it working. Also forces `GEOLIBRE_PGLITE_CDN=0`, `GEOLIBRE_CEREUS_CDN=0`, `GEOLIBRE_GDAL_CDN=0`, and `GEOLIBRE_DUCKDB_WASM_CDN=0` — so PGlite/PostGIS, CereusDB, and DuckDB-WASM stay **available**, vendored into the build under `/assets/` (at a larger build size) rather than fetched. Note that some third-party packages (DuckDB-WASM, loaders.gl, maplibre-gl-3d-tiles) carry their own internal CDN URLs that this flag cannot remove; see [architecture.md](architecture.md) for the details. Intended for deployments that cannot reference untrusted external CDNs (e.g. enterprise environments with strict CSP requirements). |
|
||||
| `GEOLIBRE_APP_NAME` | optional, e.g. `Acme Maps` | Replaces "GeoLibre" at the start of the toolbar and in the browser tab title. Whitespace runs collapse to one space and the name is capped at 60 characters. `VITE_GEOLIBRE_APP_NAME` is the equivalent build arg. |
|
||||
| `VITE_WELCOME_DISABLED=1` (build arg) | optional | Skips the first-launch wizard for every visitor. |
|
||||
| `VITE_GEOLIBRE_CAPABILITIES` (build arg) | unset, or the capabilities to grant | Unset grants everything (today's behavior). Naming a subset — or `none` — pins what the interface offers: adding data, processing, export, plugins, settings, project authoring. Removes affordances only; it is not a server-side restriction. See [Deployment Capabilities](deployment-capabilities.md). |
|
||||
| `VITE_GEOLIBRE_CAPABILITIES` (build arg) | unset, or the capabilities to grant | Unset grants everything (today's behavior). Naming a subset — or `none` — pins what the interface offers: adding data, processing, export, plugins, settings, project authoring. Build-time client grants do not configure nginx guards; set `GEOLIBRE_CAPABILITIES` or mount a policy for container enforcement. See [Deployment Capabilities](deployment-capabilities.md). |
|
||||
| `GEOLIBRE_DEPLOYMENT_FILE` | unset, or a mounted policy file | Path to a [`deployment.json`](deployment-policy.md#docker) inside the container. The entrypoint validates it, applies the `GEOLIBRE_*` overrides per field, and writes `/deployment.json` on every boot. An invalid file stops the container. |
|
||||
| `GEOLIBRE_CAPABILITIES` | unset, or the capabilities to grant | Same as `VITE_GEOLIBRE_CAPABILITIES`, but set at run time with `-e` on a prebuilt image. Unset leaves the file or build value in force; `none` grants nothing. An unknown name stops the container and lists the accepted values. See [Deployment Capabilities](deployment-capabilities.md). |
|
||||
| `GEOLIBRE_CAPABILITIES` | unset, or the capabilities to grant | Same as `VITE_GEOLIBRE_CAPABILITIES`, but set at run time with `-e` on a prebuilt image. Unset leaves the file or build value in force; `none` grants nothing. An unknown name stops the container and lists the accepted values. The final policy controls nginx sidecar guards and whether uvicorn starts. See [Deployment Capabilities](deployment-capabilities.md). |
|
||||
| `GEOLIBRE_AI_URL` / `GEOLIBRE_AI_PROXY_URL` / `GEOLIBRE_AI_PROXY_TOKEN` | unset, or the approved proxy trio | `GEOLIBRE_AI_URL=/ai` overrides the policy to enable AI. All three values must be configured together under the existing proxy validation rules. The upstream URL and token stay private to the server, not in `deployment.json`. |
|
||||
|
||||
See [Getting Started](getting-started.md#run-with-docker) for the full list.
|
||||
|
||||
@@ -167,6 +168,51 @@ See [Getting Started](getting-started.md#run-with-docker) for the full list.
|
||||
visitor's loopback interface. Drop those allowances from the CSP for a
|
||||
public deployment.
|
||||
|
||||
### Container policy enforcement
|
||||
|
||||
The container uses the final deployment policy, after environment overrides,
|
||||
to guard these route families at nginx. Each row is an **any-of** check: at
|
||||
least one listed capability must be granted.
|
||||
|
||||
| Sidecar route prefix | Required capability (any of) |
|
||||
| --- | --- |
|
||||
| `/sidecar/whitebox` | `processing:run` |
|
||||
| `/sidecar/raster` | `processing:run` |
|
||||
| `/sidecar/vector` | `processing:run` |
|
||||
| `/sidecar/pointcloud` | `processing:run` |
|
||||
| `/sidecar/ml` | `processing:run` |
|
||||
| `/sidecar/sql` | `processing:run` |
|
||||
| `/sidecar/postgis` | `data:add` |
|
||||
| `/sidecar/conversion` | `processing:run` **or** `data:add` |
|
||||
|
||||
A denied route returns HTTP 403 with an `application/json` body containing
|
||||
`detail`, rather than reaching the sidecar. Omitting `capabilities` grants all
|
||||
capabilities. If the final grant contains neither `processing:run` nor
|
||||
`data:add`, uvicorn is not started and all `/sidecar/` requests return that JSON
|
||||
403 response. `GEOLIBRE_DISABLE_SIDECAR=1` does the same regardless of the grant.
|
||||
|
||||
When uvicorn runs, existing utility routes such as `/health`, `/algorithms`,
|
||||
`/run`, and `/shutdown` remain unguarded by capabilities; this is a route-family
|
||||
policy, not per-endpoint authorization. Guards use nginx's normalized URI,
|
||||
including duplicate-slash merging and percent-decoded prefixes.
|
||||
|
||||
The final policy also controls `/ai`: `ai.enabled: false`, or an absent
|
||||
`ai.enabled`, disables the proxy. `ai.enabled: true` requires the approved
|
||||
proxy environment trio (`GEOLIBRE_AI_URL=/ai`, `GEOLIBRE_AI_PROXY_URL`, and
|
||||
`GEOLIBRE_AI_PROXY_TOKEN`); missing or invalid configuration stops boot.
|
||||
The existing environment override is preserved: setting
|
||||
`GEOLIBRE_AI_URL=/ai` enables AI even if a mounted policy says false. Merely
|
||||
setting the upstream URL or token does not enable AI. Proxy infrastructure,
|
||||
including its upstream URL and token, remains server-private.
|
||||
|
||||
!!! warning "Container guards are not general authorization"
|
||||
These restrictions apply only to the bundled container's nginx routes.
|
||||
They do not enforce capabilities on browser WASM engines, desktop
|
||||
processing, or separately exposed services. Keep the sidecar and AI
|
||||
upstream private behind nginx, retain filesystem confinement, and use
|
||||
Basic Auth or a real auth proxy for user authentication. Client-side
|
||||
hiding alone is not an authorization boundary.
|
||||
|
||||
### Putting both behind one auth layer
|
||||
|
||||
Basic Auth is fine for a single shared credential. For accounts or SSO, put
|
||||
|
||||
+108
-14
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end check of docker/entrypoint.sh and the deployment.json it writes
|
||||
# (issue #2783). Builds a small image holding the REAL entrypoint.sh, nginx.conf
|
||||
# and deployment_policy.py with a stub index.html and the Python sidecar disabled,
|
||||
# and deployment/sidecar policy with a stub index.html and the real Python sidecar,
|
||||
# so it needs no app build. The COPY/printf lines below mirror the runtime stage
|
||||
# of the Dockerfile (COPY docker/..., default auth and AI snippets) and must be
|
||||
# kept in sync with it.
|
||||
@@ -15,6 +15,9 @@ IMAGE=geolibre-entrypoint:test
|
||||
FIXTURES=tests/fixtures/deployment-policy
|
||||
TMP="$(mktemp -d)"
|
||||
CONTAINERS=()
|
||||
RUN_ID="geolibre-entrypoint-$(basename "$TMP")"
|
||||
exec_container() { local name="$1"; shift; docker exec "$RUN_ID-$name" "$@"; }
|
||||
container_logs() { docker logs "$RUN_ID-$1"; }
|
||||
|
||||
cleanup() {
|
||||
for name in "${CONTAINERS[@]:-}"; do
|
||||
@@ -34,25 +37,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends nginx openssl c
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf.template
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY docker/deployment_policy.py /usr/local/lib/geolibre/deployment_policy.py
|
||||
COPY docker/sidecar_policy.py /usr/local/lib/geolibre/sidecar_policy.py
|
||||
COPY backend/geolibre_server /opt/geolibre-server
|
||||
RUN pip install --no-cache-dir /opt/geolibre-server pytest
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||
&& printf '# Basic Auth disabled.\n' > /etc/nginx/geolibre-auth.conf \
|
||||
&& printf '# AI proxy disabled.\n' > /etc/nginx/geolibre-ai-proxy.conf \
|
||||
&& printf '# Sidecar guards disabled.\n' > /etc/nginx/geolibre-sidecar-guards.conf \
|
||||
&& printf '# Sidecar enabled.\n' > /etc/nginx/geolibre-sidecar-off.conf \
|
||||
&& mkdir -p /usr/share/nginx/html \
|
||||
&& printf '<!doctype html><title>stub</title>\n' > /usr/share/nginx/html/index.html
|
||||
ENV GEOLIBRE_DISABLE_SIDECAR=1
|
||||
CMD ["/usr/local/bin/entrypoint.sh"]
|
||||
DOCKERFILE
|
||||
|
||||
# start NAME DOCKER_ARGS... : run detached and wait for /healthz.
|
||||
start() {
|
||||
local name="$1"; shift
|
||||
CONTAINERS+=("$name")
|
||||
docker run -d --name "$name" "$@" "$IMAGE" >/dev/null
|
||||
docker run -d --name "$RUN_ID-$name" "$@" "$IMAGE" >/dev/null
|
||||
CONTAINERS+=("$RUN_ID-$name")
|
||||
for _ in $(seq 1 60); do
|
||||
if docker exec "$name" curl -fsS http://127.0.0.1/healthz >/dev/null 2>&1; then return 0; fi
|
||||
if exec_container "$name" curl -fsS http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
exec_container "$name" nginx -t || fail "$name: nginx configuration"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
docker logs "$name" >&2 || true
|
||||
container_logs "$name" >&2 || true
|
||||
fail "$name did not become healthy"
|
||||
}
|
||||
|
||||
@@ -67,28 +77,63 @@ expect_fail() {
|
||||
}
|
||||
|
||||
mount_policy() { echo "-v $1:/etc/geolibre/deployment.json:ro -e GEOLIBRE_DEPLOYMENT_FILE=/etc/geolibre/deployment.json"; }
|
||||
fetch() { docker exec "$1" curl -fsS "http://127.0.0.1$2"; }
|
||||
fetch() { exec_container "$1" curl -fsS "http://127.0.0.1$2"; }
|
||||
json_eq() { python3 -c 'import json,sys; a=json.loads(sys.argv[1]); b=json.load(open(sys.argv[2])); b.pop("$schema",None); sys.exit(0 if a==b else 1)' "$1" "$2"; }
|
||||
|
||||
# A proxy response must come from the real backend, not an nginx fallback.
|
||||
sidecar_ready() {
|
||||
local name="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
if fetch "$name" /sidecar/health 2>/dev/null | python3 -c 'import json,sys; assert json.load(sys.stdin)["status"] == "ok"' 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail "$name: sidecar did not start"
|
||||
}
|
||||
|
||||
no_sidecar() {
|
||||
exec_container "$1" python -c 'import os; from pathlib import Path; assert not any(b"uvicorn" in p.read_bytes() for p in Path("/proc").glob("[0-9]*/cmdline") if p.parent.name != str(os.getpid())), "uvicorn is running"' \
|
||||
|| fail "$1: uvicorn should not run"
|
||||
}
|
||||
|
||||
status_eq() {
|
||||
local name="$1" path="$2" expected="$3" status
|
||||
status="$(exec_container "$name" curl --path-as-is -sS -o /tmp/response -w '%{http_code}' "http://127.0.0.1$path")"
|
||||
[ "$status" = "$expected" ] || fail "$name $path: expected $expected, got $status"
|
||||
}
|
||||
|
||||
forbidden() {
|
||||
status_eq "$1" "$2" 403
|
||||
exec_container "$1" curl --path-as-is -sSI "http://127.0.0.1$2" \
|
||||
| grep -qi '^content-type: application/json' || fail "$1 $2: not JSON"
|
||||
exec_container "$1" cat /tmp/response \
|
||||
| python3 -c 'import json,sys; assert isinstance(json.load(sys.stdin)["detail"], str)' \
|
||||
|| fail "$1 $2: missing JSON detail"
|
||||
}
|
||||
|
||||
# 1. defaults ------------------------------------------------------------------
|
||||
start defaults
|
||||
headers="$(docker exec defaults curl -si http://127.0.0.1/deployment.json)"
|
||||
headers="$(exec_container defaults curl -si http://127.0.0.1/deployment.json)"
|
||||
grep -q '^HTTP/1.1 200' <<<"$headers" || fail "defaults: status"
|
||||
grep -qi '^content-type: application/json' <<<"$headers" || fail "defaults: content-type"
|
||||
grep -qi '^cache-control: no-store' <<<"$headers" || fail "defaults: cache-control"
|
||||
grep -qi '^x-content-type-options: nosniff' <<<"$headers" || fail "defaults: nosniff"
|
||||
[ "$(fetch defaults /deployment.json | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)))')" = '{"version": 1}' ] \
|
||||
|| fail "defaults: body"
|
||||
[ "$(docker exec defaults cat /usr/share/nginx/html/geolibre-runtime-config.js)" = 'window.__GEOLIBRE_DEPLOYMENT_ENV__ = {};' ] \
|
||||
[ "$(exec_container defaults cat /usr/share/nginx/html/geolibre-runtime-config.js)" = 'window.__GEOLIBRE_DEPLOYMENT_ENV__ = {};' ] \
|
||||
|| fail "defaults: runtime config changed"
|
||||
! docker logs defaults 2>&1 | grep -q ' from GEOLIBRE_' || fail "defaults: unexpected override log"
|
||||
! container_logs defaults 2>&1 | grep -q ' from GEOLIBRE_' || fail "defaults: unexpected override log"
|
||||
ok "defaults: empty policy, headers, runtime config unchanged"
|
||||
sidecar_ready defaults
|
||||
status_eq defaults /sidecar/postgis/status 200
|
||||
status_eq defaults /sidecar/conversion/status 200
|
||||
|
||||
# 2. capabilities env ----------------------------------------------------------
|
||||
start caps -e GEOLIBRE_CAPABILITIES=data:add,export:data
|
||||
json_eq "$(fetch caps /deployment.json)" <(echo '{"version":1,"capabilities":["data:add","export:data"]}') \
|
||||
|| fail "caps: body"
|
||||
docker exec caps cat /usr/share/nginx/html/geolibre-runtime-config.js \
|
||||
exec_container caps cat /usr/share/nginx/html/geolibre-runtime-config.js \
|
||||
| grep -qF '"VITE_GEOLIBRE_CAPABILITIES":"data:add,export:data"' || fail "caps: runtime config"
|
||||
ok "GEOLIBRE_CAPABILITIES serves exactly those capabilities"
|
||||
|
||||
@@ -99,9 +144,9 @@ start mixed $(mount_policy "$TMP/mixed.json") -e "GEOLIBRE_APP_NAME=From Env"
|
||||
body="$(fetch mixed /deployment.json)"
|
||||
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); assert d["branding"]["appName"]=="From Env" and d["capabilities"]==["data:add"], d' "$body" \
|
||||
|| fail "mixed: body"
|
||||
lines="$(docker logs mixed 2>&1 | grep -c '^Deployment policy: .* from GEOLIBRE_' || true)"
|
||||
lines="$(container_logs mixed 2>&1 | grep -c '^Deployment policy: .* from GEOLIBRE_' || true)"
|
||||
[ "$lines" = 1 ] || fail "mixed: expected exactly one override log line, got $lines"
|
||||
docker logs mixed 2>&1 | grep '^Deployment policy: .* from GEOLIBRE_' | grep -q '(overrides GEOLIBRE_DEPLOYMENT_FILE)$' \
|
||||
container_logs mixed 2>&1 | grep '^Deployment policy: .* from GEOLIBRE_' | grep -q '(overrides GEOLIBRE_DEPLOYMENT_FILE)$' \
|
||||
|| fail "mixed: override log suffix"
|
||||
ok "env overrides one file field with one log line"
|
||||
|
||||
@@ -113,7 +158,7 @@ json_eq "$(fetch minimal /deployment.json)" "$FIXTURES/good/minimal.json" || fai
|
||||
start full $(mount_policy "$PWD/$FIXTURES/good/full.json") \
|
||||
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
json_eq "$(fetch full /deployment.json)" "$FIXTURES/good/full.json" || fail "full: round trip"
|
||||
docker exec full curl -sI http://127.0.0.1/ | grep -i '^content-security-policy' | grep -qF ' wss://relay.example.com' \
|
||||
exec_container full curl -sI http://127.0.0.1/ | grep -i '^content-security-policy' | grep -qF ' wss://relay.example.com' \
|
||||
|| fail "full: CSP lacks the collab origin from the file"
|
||||
ok "good fixtures round-trip"
|
||||
|
||||
@@ -140,3 +185,52 @@ echo '{"version":1,"ai":{"enabled":true}}' >"$TMP/ai.json"
|
||||
# shellcheck disable=SC2046
|
||||
expect_fail "ai.enabled is true, but the AI proxy is not configured" $(mount_policy "$TMP/ai.json")
|
||||
ok "bad env fails the boot"
|
||||
|
||||
# 7. sidecar capability enforcement --------------------------------------------
|
||||
sidecar_ready caps
|
||||
status_eq caps /sidecar/postgis/status 200
|
||||
status_eq caps /sidecar/conversion/status 200
|
||||
for path in /sidecar/whitebox/status /sidecar//whitebox/status /sidecar/%77hitebox/status /sidecar%2fwhitebox/status /sidecar/whitebox%2fstatus /sidecar/whitebox /sidecar/whitebox/status.js; do
|
||||
forbidden caps "$path"
|
||||
done
|
||||
start processing -e GEOLIBRE_CAPABILITIES=processing:run
|
||||
sidecar_ready processing
|
||||
status_eq processing /sidecar/conversion/status 200
|
||||
status_eq processing /sidecar/conversion/jobs/x 404
|
||||
forbidden processing /sidecar/postgis/status
|
||||
start export-only -e GEOLIBRE_CAPABILITIES=export:data
|
||||
no_sidecar export-only
|
||||
for path in /sidecar/health /sidecar/postgis/status /sidecar/unknown /sidecar/unknown.js /sidecar//whitebox/status /sidecar/%77hitebox/status /sidecar/whitebox; do
|
||||
forbidden export-only "$path"
|
||||
done
|
||||
start sidecar-disabled -e GEOLIBRE_DISABLE_SIDECAR=1
|
||||
no_sidecar sidecar-disabled
|
||||
forbidden sidecar-disabled /sidecar/health
|
||||
forbidden sidecar-disabled /sidecar/conversion/status
|
||||
ok "sidecar capabilities guard normalized paths and control the real process"
|
||||
|
||||
# 8. final AI policy controls the actual proxy ----------------------------------
|
||||
forbidden defaults /ai/chat/completions
|
||||
echo '{"version":1,"ai":{"enabled":false}}' >"$TMP/ai-false.json"
|
||||
# Proxy credentials alone must not turn an explicitly disabled policy on.
|
||||
# shellcheck disable=SC2046
|
||||
start ai-false $(mount_policy "$TMP/ai-false.json") \
|
||||
-e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
forbidden ai-false /ai/chat/completions
|
||||
start ai-absent -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
forbidden ai-absent /ai/chat/completions
|
||||
status_eq full /ai/chat/completions 502
|
||||
# Part 3's URL override enables AI even when the file disables it.
|
||||
# shellcheck disable=SC2046
|
||||
start ai-override $(mount_policy "$TMP/ai-false.json") \
|
||||
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
status_eq ai-override /ai/chat/completions 502
|
||||
# shellcheck disable=SC2046
|
||||
expect_fail "must be set together" $(mount_policy "$TMP/ai.json") -e GEOLIBRE_AI_URL=/ai
|
||||
expect_fail "must be the same-origin path /ai" \
|
||||
-e GEOLIBRE_AI_URL=/wrong -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
expect_fail "must contain only [A-Za-z0-9._-]" \
|
||||
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=https://127.0.0.1:9 -e 'GEOLIBRE_AI_PROXY_TOKEN=bad token'
|
||||
expect_fail "must be an HTTPS origin without credentials, path, query, or fragment" \
|
||||
-e GEOLIBRE_AI_URL=/ai -e GEOLIBRE_AI_PROXY_URL=http://127.0.0.1:9 -e GEOLIBRE_AI_PROXY_TOKEN=test-token
|
||||
ok "final AI policy gates the proxy and preserves boot validation"
|
||||
Reference in new issue
Block a user