fix(deploy): harden security in deployment templates and explorer app

- GCP: remove --allow-unauthenticated, restrict ingress to
  internal-and-cloud-load-balancing, replace wildcard ALLOWED_ORIGINS=*
  with a substitution variable (_ALLOWED_ORIGINS) so operators supply a
  real URL at deploy time; same fix in cloudrun-service.yaml
- Fly.io: replace hardcoded FALKORDB_HOST=localhost with the correct
  .internal private-network hostname pattern; update README accordingly
- docker-compose.dev.yml: add missing top-level networks: block so the
  frontend service can join the semantica network without --file layering
- K8s/Helm: add readOnlyRootFilesystem: true + runAsUser: 1000 to
  container securityContext; mount an emptyDir /tmp so uvicorn can write
  temp files
- app.py: fix _read_explorer_settings() or-chain, use in os.environ
  checks so an explicit ALLOWED_ORIGINS="" produces an empty allow-list
  instead of silently falling through to localhost defaults; remove dead
  app.state.falkordb_host/port attributes
- docs: update four locations that still documented {"status":"healthy"}
  to reflect the new {"status":"ok"} health response
- tests: update test assertion to read falkordb settings from
  app.state.explorer_settings instead of removed top-level attributes
This commit is contained in:
KaifAhmad1
2026-06-24 12:51:09 +05:30
parent 21ddee94f7
commit b2c949f7de
14 changed files with 58 additions and 20 deletions
+4 -1
View File
@@ -5,7 +5,10 @@ Deploy from a clean checkout using the root Dockerfile:
```bash ```bash
flyctl auth login flyctl auth login
flyctl launch --copy-config --config deploy/fly/fly.toml --no-deploy flyctl launch --copy-config --config deploy/fly/fly.toml --no-deploy
flyctl secrets set FALKORDB_HOST=localhost FALKORDB_PORT=6379 # Replace <falkordb-app-name> with your FalkorDB Fly app name.
# Fly.io private networking uses .internal hostnames — do not use localhost
# unless FalkorDB is a co-located process inside the same Machine.
flyctl secrets set FALKORDB_HOST=<falkordb-app-name>.internal FALKORDB_PORT=6379
flyctl deploy --config deploy/fly/fly.toml flyctl deploy --config deploy/fly/fly.toml
``` ```
+3 -1
View File
@@ -8,7 +8,9 @@ dockerfile = "Dockerfile"
[env] [env]
ALLOWED_ORIGINS = "https://semantica-knowledge-explorer.fly.dev" ALLOWED_ORIGINS = "https://semantica-knowledge-explorer.fly.dev"
FALKORDB_HOST = "localhost" # Set via: flyctl secrets set FALKORDB_HOST=<your-falkordb-app-name>.internal FALKORDB_PORT=6379
# Do not use localhost unless FalkorDB runs as a co-located process in the same Machine.
FALKORDB_HOST = "falkordb-REPLACE_ME.internal"
FALKORDB_PORT = "6379" FALKORDB_PORT = "6379"
[http_service] [http_service]
+3 -1
View File
@@ -6,7 +6,9 @@ Create the Secret Manager entries, then submit the Cloud Build pipeline:
gcloud services enable cloudbuild.googleapis.com run.googleapis.com secretmanager.googleapis.com gcloud services enable cloudbuild.googleapis.com run.googleapis.com secretmanager.googleapis.com
printf "falkordb-host.example.internal" | gcloud secrets create falkordb-host --data-file=- printf "falkordb-host.example.internal" | gcloud secrets create falkordb-host --data-file=-
printf "6379" | gcloud secrets create falkordb-port --data-file=- printf "6379" | gcloud secrets create falkordb-port --data-file=-
gcloud builds submit --config deploy/gcp/cloudbuild.yaml --substitutions _REGION=us-central1,_SERVICE_NAME=knowledge-explorer # Set _ALLOWED_ORIGINS to your actual service URL after the first deploy.
gcloud builds submit --config deploy/gcp/cloudbuild.yaml \
--substitutions _REGION=us-central1,_SERVICE_NAME=knowledge-explorer,_ALLOWED_ORIGINS=https://knowledge-explorer-REPLACE_ME.a.run.app
``` ```
For declarative deploys, replace `PROJECT_ID` in `cloudrun-service.yaml`, then run: For declarative deploys, replace `PROJECT_ID` in `cloudrun-service.yaml`, then run:
+11 -2
View File
@@ -2,6 +2,8 @@ substitutions:
_REGION: us-central1 _REGION: us-central1
_SERVICE_NAME: knowledge-explorer _SERVICE_NAME: knowledge-explorer
_IMAGE: gcr.io/$PROJECT_ID/knowledge-explorer _IMAGE: gcr.io/$PROJECT_ID/knowledge-explorer
# Set to your actual service URL — do not use '*' in production.
_ALLOWED_ORIGINS: https://knowledge-explorer-REPLACE_ME.a.run.app
steps: steps:
- name: gcr.io/cloud-builders/docker - name: gcr.io/cloud-builders/docker
@@ -35,7 +37,12 @@ steps:
- ${_REGION} - ${_REGION}
- --platform - --platform
- managed - managed
- --allow-unauthenticated # SECURITY: Remove --allow-unauthenticated and restrict ingress for
# production; add IAP or a load balancer with auth before enabling
# unauthenticated access. See: cloud.google.com/run/docs/authenticating
- --no-allow-unauthenticated
- --ingress
- internal-and-cloud-load-balancing
- --port - --port
- "8000" - "8000"
- --min-instances - --min-instances
@@ -43,7 +50,9 @@ steps:
- --max-instances - --max-instances
- "10" - "10"
- --set-env-vars - --set-env-vars
- ALLOWED_ORIGINS=* # Replace with your actual Cloud Run service URL after first deploy,
# e.g. ALLOWED_ORIGINS=https://knowledge-explorer-abc123-uc.a.run.app
- ALLOWED_ORIGINS=${_ALLOWED_ORIGINS}
- --set-secrets - --set-secrets
- FALKORDB_HOST=falkordb-host:latest,FALKORDB_PORT=falkordb-port:latest - FALKORDB_HOST=falkordb-host:latest,FALKORDB_PORT=falkordb-port:latest
+5 -2
View File
@@ -3,7 +3,9 @@ kind: Service
metadata: metadata:
name: knowledge-explorer name: knowledge-explorer
annotations: annotations:
run.googleapis.com/ingress: all # Use 'internal-and-cloud-load-balancing' or 'internal' in production.
# 'all' permits direct unauthenticated public internet access.
run.googleapis.com/ingress: internal-and-cloud-load-balancing
spec: spec:
template: template:
metadata: metadata:
@@ -20,7 +22,8 @@ spec:
containerPort: 8000 containerPort: 8000
env: env:
- name: ALLOWED_ORIGINS - name: ALLOWED_ORIGINS
value: "*" # Replace with your actual service URL — do not use '*' in production.
value: "https://knowledge-explorer-REPLACE_ME.a.run.app"
- name: FALKORDB_HOST - name: FALKORDB_HOST
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -75,6 +75,12 @@ spec:
failureThreshold: {{ .Values.readinessProbe.failureThreshold }} failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
{{- with .Values.nodeSelector }} {{- with .Values.nodeSelector }}
nodeSelector: nodeSelector:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
@@ -19,6 +19,8 @@ podSecurityContext:
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsUser: 1000
capabilities: capabilities:
drop: drop:
- ALL - ALL
+8
View File
@@ -64,6 +64,14 @@ spec:
memory: 512Mi memory: 512Mi
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsUser: 1000
capabilities: capabilities:
drop: drop:
- ALL - ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
+4
View File
@@ -38,5 +38,9 @@ services:
networks: networks:
- semantica - semantica
networks:
semantica:
driver: bridge
volumes: volumes:
explorer_node_modules: explorer_node_modules:
+1 -1
View File
@@ -69,7 +69,7 @@ python -c "import semantica; print(semantica.__version__)"
```bash ```bash
curl http://localhost:8000/health curl http://localhost:8000/health
# {"status": "healthy"} # {"status": "ok"}
curl http://localhost:8000/api/info curl http://localhost:8000/api/info
# {"name": "Semantica API", "version": "...", "status": "active"} # {"name": "Semantica API", "version": "...", "status": "active"}
+2 -2
View File
@@ -56,7 +56,7 @@ The browser opens at `http://127.0.0.1:8000`. The health endpoint confirms the s
```bash ```bash
curl http://127.0.0.1:8000/api/health curl http://127.0.0.1:8000/api/health
# {"status": "healthy"} # {"status": "ok"}
``` ```
@@ -174,7 +174,7 @@ Once the server is running:
| :--- | :------------ | | :--- | :------------ |
| `http://127.0.0.1:8000` | Interactive dashboard | | `http://127.0.0.1:8000` | Interactive dashboard |
| `http://127.0.0.1:8000/docs` | Swagger UI: every REST endpoint, interactive | | `http://127.0.0.1:8000/docs` | Swagger UI: every REST endpoint, interactive |
| `http://127.0.0.1:8000/api/health` | Health check: `{"status": "healthy"}` | | `http://127.0.0.1:8000/api/health` | Health check: `{"status": "ok"}` |
The browser tab opens shortly after startup. If it does not open, navigate to the URL manually or pass `--no-browser` and open it yourself. The browser tab opens shortly after startup. If it does not open, navigate to the URL manually or pass `--no-browser` and open it yourself.
+1 -1
View File
@@ -306,7 +306,7 @@ Full interactive docs at `http://localhost:8000/docs`. All endpoints accept and
| Endpoint | Method | Description | | Endpoint | Method | Description |
| :-------- | :------ | :----------- | | :-------- | :------ | :----------- |
| `/api/health` | `GET` | Returns `{"status": "healthy"}` | | `/api/health` | `GET` | Returns `{"status": "ok"}` |
| `/api/info` | `GET` | Server name, version, status | | `/api/info` | `GET` | Server name, version, status |
| `/docs` | `GET` | Interactive Swagger UI: all endpoints | | `/docs` | `GET` | Interactive Swagger UI: all endpoints |
+6 -7
View File
@@ -30,11 +30,12 @@ def _read_int_env(name: str, default: int) -> int:
def _read_explorer_settings() -> dict: def _read_explorer_settings() -> dict:
raw_origins = ( if "ALLOWED_ORIGINS" in os.environ:
os.environ.get("ALLOWED_ORIGINS") raw_origins = os.environ["ALLOWED_ORIGINS"]
or os.environ.get("EXPLORER_CORS_ORIGINS") elif "EXPLORER_CORS_ORIGINS" in os.environ:
or "http://localhost:5173,http://127.0.0.1:5173" raw_origins = os.environ["EXPLORER_CORS_ORIGINS"]
) else:
raw_origins = "http://localhost:5173,http://127.0.0.1:5173"
return { return {
"allowed_origins": [ "allowed_origins": [
origin.strip() for origin in raw_origins.split(",") if origin.strip() origin.strip() for origin in raw_origins.split(",") if origin.strip()
@@ -88,8 +89,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
) )
app.state.explorer_settings = settings app.state.explorer_settings = settings
app.state.falkordb_host = settings["falkordb_host"]
app.state.falkordb_port = settings["falkordb_port"]
app.state.allowed_origins = settings["allowed_origins"] app.state.allowed_origins = settings["allowed_origins"]
# allow_credentials lets browsers send cookies/auth headers cross-origin. # allow_credentials lets browsers send cookies/auth headers cross-origin.
+2 -2
View File
@@ -175,8 +175,8 @@ class TestHealthInfo:
"https://app.example.com", "https://app.example.com",
"https://team.example.com", "https://team.example.com",
] ]
assert app.state.falkordb_host == "falkordb.internal" assert app.state.explorer_settings["falkordb_host"] == "falkordb.internal"
assert app.state.falkordb_port == 6380 assert app.state.explorer_settings["falkordb_port"] == 6380
def test_env_settings_fall_back_to_legacy_cors_name(self, monkeypatch): def test_env_settings_fall_back_to_legacy_cors_name(self, monkeypatch):
monkeypatch.delenv("ALLOWED_ORIGINS", raising=False) monkeypatch.delenv("ALLOWED_ORIGINS", raising=False)