Compare commits
3 Commits
b6165bc0b2
...
fd5f12a8ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd5f12a8ca | ||
|
|
8c3795ec8c | ||
|
|
e663b007d4 |
@@ -1,7 +1,8 @@
|
|||||||
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
||||||
# Datenbank: Postgres (docker-compose setzt sie selbst) oder SQLite für lokale Entwicklung
|
# Datenbank: Postgres (docker-compose setzt sie selbst) oder SQLite für lokale Entwicklung
|
||||||
DATABASE_URL=sqlite:///./.data/onyscribe-admin.db
|
DATABASE_URL=sqlite:///./.data/onyscribe-admin.db
|
||||||
# Master-Key für Secrets in der DB UND Session-Cookies (Fernet-Key). SICHERN – ohne ihn sind Secrets unlesbar.
|
# Master-Key für Secrets in der DB UND Session-Cookies (Fernet-Key, 44 Zeichen). SICHERN – ohne ihn sind Secrets unlesbar.
|
||||||
|
# python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
|
||||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ from cryptography.fernet import Fernet
|
|||||||
|
|
||||||
from app.settings import SECRET_KEY
|
from app.settings import SECRET_KEY
|
||||||
|
|
||||||
|
try:
|
||||||
_f = Fernet(SECRET_KEY.encode())
|
_f = Fernet(SECRET_KEY.encode())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"SECRET_KEY ist kein gültiger Fernet-Schlüssel (erwartet: 32 Zufallsbytes in URL-sicherem Base64, 44 Zeichen, endet meist mit „=“). "
|
||||||
|
"Erzeugen mit: python3 -c \"import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())\" "
|
||||||
|
"– und in Coolify als SECRET_KEY setzen (außerhalb von Coolify sichern; ohne ihn sind die Secrets in der Datenbank nicht entschlüsselbar)."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def encrypt(v: str) -> str:
|
def encrypt(v: str) -> str:
|
||||||
|
|||||||
@@ -20,10 +20,17 @@ from app.services import contract, crypto, guard, onyva_login, store
|
|||||||
from app.services.mittwald import DeployError, Mittwald, mask_env
|
from app.services.mittwald import DeployError, Mittwald, mask_env
|
||||||
|
|
||||||
PORT = "8276"
|
PORT = "8276"
|
||||||
|
# Geteilte Secrets, die – falls nicht in der DB gesetzt – aus der Env der Admin-Plattform kommen (gleicher Key wie für Agent/Prompt-Test)
|
||||||
|
ENV_FALLBACK_SECRETS = ("CORTECS_API_KEY",)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_secret(key: str) -> str:
|
||||||
|
return os.getenv(key, "").strip() if key in ENV_FALLBACK_SECRETS else ""
|
||||||
|
|
||||||
|
|
||||||
def live_enabled() -> bool:
|
def live_enabled() -> bool:
|
||||||
return os.getenv("DEPLOY_LIVE", "false").lower() == "true"
|
"""DEPLOY_LIVE=true UND aktiver Login (guard.auth_ok) – ohne Login bleibt alles im Dry-Run."""
|
||||||
|
return os.getenv("DEPLOY_LIVE", "false").lower() == "true" and guard.auth_ok()
|
||||||
|
|
||||||
|
|
||||||
def token_present() -> bool:
|
def token_present() -> bool:
|
||||||
@@ -74,6 +81,10 @@ def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]:
|
|||||||
env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt"
|
env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt"
|
||||||
elif not reveal:
|
elif not reveal:
|
||||||
env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)"
|
env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)"
|
||||||
|
for k in ENV_FALLBACK_SECRETS: # Admin-Env als Rückfall für geteilte Secrets ohne DB-Wert
|
||||||
|
v = _env_secret(k)
|
||||||
|
if v and (k not in env or not env[k] or env[k].startswith("⚠")):
|
||||||
|
env[k] = v if reveal else "•••• gesetzt (aus Admin-Env)"
|
||||||
if t.source == "admin":
|
if t.source == "admin":
|
||||||
env["TENANT_SOURCE"] = "admin"
|
env["TENANT_SOURCE"] = "admin"
|
||||||
env["ADMIN_URL"] = setting(db, "admin_public_url", "https://<admin-host>")
|
env["ADMIN_URL"] = setting(db, "admin_public_url", "https://<admin-host>")
|
||||||
@@ -84,6 +95,7 @@ def missing_secrets(db, t: Tenant) -> list[str]:
|
|||||||
"""Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben."""
|
"""Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben."""
|
||||||
cfg, _ = store.content(db, t, "current")
|
cfg, _ = store.content(db, t, "current")
|
||||||
have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc}
|
have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc}
|
||||||
|
have |= {k for k in ENV_FALLBACK_SECRETS if _env_secret(k)}
|
||||||
need = set(contract.required_secrets(cfg))
|
need = set(contract.required_secrets(cfg))
|
||||||
if t.source == "admin":
|
if t.source == "admin":
|
||||||
need.add("ADMIN_BUNDLE_TOKEN")
|
need.add("ADMIN_BUNDLE_TOKEN")
|
||||||
|
|||||||
@@ -33,3 +33,10 @@ def describe() -> str:
|
|||||||
def denial(tenant_id: str) -> str:
|
def denial(tenant_id: str) -> str:
|
||||||
return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). "
|
return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). "
|
||||||
"Dry-Runs sind weiterhin möglich.")
|
"Dry-Runs sind weiterhin möglich.")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_ok() -> bool:
|
||||||
|
"""Live-Schreibzugriffe nur mit aktivem Login der Admin-Plattform (sonst wäre sie – mit Mittwald-Token im Container – für jeden offen).
|
||||||
|
Nur für lokale Experimente übersteuerbar: ALLOW_NO_AUTH_LIVE=true."""
|
||||||
|
from app.settings import AUTH_ENABLED
|
||||||
|
return AUTH_ENABLED or os.getenv("ALLOW_NO_AUTH_LIVE", "false").lower() == "true"
|
||||||
|
|||||||
@@ -83,13 +83,21 @@ class Mittwald:
|
|||||||
stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False)
|
stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False)
|
||||||
except DryRunSkip:
|
except DryRunSkip:
|
||||||
return "<STACK_ID>"
|
return "<STACK_ID>"
|
||||||
items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", [])
|
items = stacks if isinstance(stacks, list) else ((stacks or {}).get("items") or (stacks or {}).get("stacks") or [])
|
||||||
for s in items:
|
for s in items:
|
||||||
if str(s.get("description") or s.get("name") or "").lower() == "default":
|
if str(s.get("description") or s.get("name") or "").lower() == "default":
|
||||||
return s["id"]
|
return s["id"]
|
||||||
if items:
|
if items:
|
||||||
return items[0]["id"]
|
return items[0]["id"]
|
||||||
raise DeployError("Kein Stack im Projekt gefunden.")
|
# Rückfall: Jedes Mittwald-Projekt hat einen Standard-Stack, dessen ID der UUID des Projekts entspricht
|
||||||
|
proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False)
|
||||||
|
uuid = (proj or {}).get("id")
|
||||||
|
if uuid and self.try_api("GET", f"/stacks/{uuid}/", write=False, fatal=False) is not None:
|
||||||
|
self.log(f"Stack über die Projekt-UUID gefunden: {uuid}")
|
||||||
|
return uuid
|
||||||
|
raw = json.dumps(stacks, ensure_ascii=False)[:200]
|
||||||
|
raise DeployError(f"Kein Stack im Projekt gefunden (Antwort der API: {raw}). STACK_ID in den Einstellungen von Hand eintragen "
|
||||||
|
f"(mStudio bzw. GET /v2/projects/{project_id}/stacks) und Token-Rechte für Container/Stacks prüfen.")
|
||||||
|
|
||||||
def get_stack(self, stack_id: str) -> dict | None:
|
def get_stack(self, stack_id: str) -> dict | None:
|
||||||
return self.try_api("GET", f"/stacks/{stack_id}/", write=False)
|
return self.try_api("GET", f"/stacks/{stack_id}/", write=False)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def key_present() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def writes_allowed() -> bool:
|
def writes_allowed() -> bool:
|
||||||
return os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true"
|
return (os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true") and guard.auth_ok()
|
||||||
|
|
||||||
|
|
||||||
def base_url(db) -> str:
|
def base_url(db) -> str:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
|
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
|
||||||
{% set u = request.state.user if request is defined else None %}
|
{% set u = request.state.user if request is defined else None %}
|
||||||
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill dev">Login: aus</span>{% endif %}
|
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill live" title="AUTH_ENABLED ist nicht true: jeder mit der URL hat vollen Zugriff. Live-Aktionen sind gesperrt.">Login aus – offen!</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="main">{% block body %}{% endblock %}</div>
|
<div class="main">{% block body %}{% endblock %}</div>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ Prüfen:
|
|||||||
- [ ] `https://<ADMIN_HOST>/healthz` → `{"ok":true}`
|
- [ ] `https://<ADMIN_HOST>/healthz` → `{"ok":true}`
|
||||||
- [ ] `https://<ADMIN_HOST>/` → Weiterleitung zu login.onyva.dev → Login → Dashboard mit **7 Tenants** (Seed).
|
- [ ] `https://<ADMIN_HOST>/` → Weiterleitung zu login.onyva.dev → Login → Dashboard mit **7 Tenants** (Seed).
|
||||||
- [ ] Seitenleiste unten: „Deploy: gesperrt · Dry-Run“, „<DEINE_MAIL>“.
|
- [ ] Seitenleiste unten: „Deploy: gesperrt · Dry-Run“, „<DEINE_MAIL>“.
|
||||||
|
- [ ] `https://<ADMIN_HOST>/auth/me` zeigt `"auth_enabled": true`. Steht dort `false` (Seitenleiste: rot „Login aus – offen!“), ist die Plattform für jeden offen: `AUTH_ENABLED=true` (ohne Anführungszeichen) und die OIDC-Variablen prüfen. Ohne Login sind Live-Aktionen automatisch gesperrt.
|
||||||
- Probleme: „Kein Zugriff“ → `ADMIN_ALLOWED_EMAILS`; `redirect_uri`-Fehler → Redirect-URI im Login-Client **exakt** wie in `OIDC_REDIRECT_URI`.
|
- Probleme: „Kein Zugriff“ → `ADMIN_ALLOWED_EMAILS`; `redirect_uri`-Fehler → Redirect-URI im Login-Client **exakt** wie in `OIDC_REDIRECT_URI`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1" # .env nicht einlesen
|
os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1"
|
||||||
|
os.environ["ALLOW_NO_AUTH_LIVE"] = "true" # Tests laufen ohne Login; der Guard wird separat getestet # .env nicht einlesen
|
||||||
for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE",
|
for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE",
|
||||||
"AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER",
|
"AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER",
|
||||||
"ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"):
|
"ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"):
|
||||||
|
|||||||
@@ -441,3 +441,54 @@ def test_live_deploy_requires_admin_public_url_for_admin_source(client, live_env
|
|||||||
d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
|
d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
|
||||||
assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"]
|
assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"]
|
||||||
assert client.get("/api/tenants/pf2/preflight").status_code == 200
|
assert client.get("/api/tenants/pf2/preflight").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_actions_are_locked_without_login(client, monkeypatch):
|
||||||
|
"""Ohne aktiven Login der Admin-Plattform bleibt alles im Dry-Run – auch mit DEPLOY_LIVE=true / OIDC_SYNC_LIVE=true."""
|
||||||
|
from app.services import onyva_login
|
||||||
|
monkeypatch.setenv("DEPLOY_LIVE", "true")
|
||||||
|
monkeypatch.setenv("OIDC_SYNC_LIVE", "true")
|
||||||
|
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
|
||||||
|
assert deploy.live_enabled() and onyva_login.writes_allowed()
|
||||||
|
monkeypatch.delenv("ALLOW_NO_AUTH_LIVE") # kein Login, keine Ausnahme
|
||||||
|
assert not deploy.live_enabled() and not onyva_login.writes_allowed()
|
||||||
|
assert client.post("/api/tenants/sprinz/deploy", json={"dry_run": False}).status_code == 403
|
||||||
|
assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403
|
||||||
|
assert client.get("/api/deploy/mode").json()["live"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stack-Auflösung & Env-Rückfall ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_stack_resolution_falls_back_to_project_uuid_and_explains_failures():
|
||||||
|
from app.services.mittwald import DeployError, Mittwald
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def http_ok(method, url, headers, body):
|
||||||
|
path = url.split("/v2")[1]
|
||||||
|
calls.append(path)
|
||||||
|
if path.endswith("/stacks/") and "/projects/" in path:
|
||||||
|
return 200, [], "" # Liste leer
|
||||||
|
if path == "/projects/p-1/":
|
||||||
|
return 200, {"id": "uuid-1"}, ""
|
||||||
|
if path == "/stacks/uuid-1/":
|
||||||
|
return 200, {"services": {}}, ""
|
||||||
|
return 404, None, "nope"
|
||||||
|
|
||||||
|
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, http_ok).resolve_stack_id("p-1", "") == "uuid-1"
|
||||||
|
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, [{"id": "s-9", "description": "default"}], "")).resolve_stack_id("p-1", "") == "s-9"
|
||||||
|
assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, {"stacks": [{"id": "s-7"}]}, "")).resolve_stack_id("p-1", "") == "s-7"
|
||||||
|
with pytest.raises(DeployError, match="Antwort der API.*STACK_ID in den Einstellungen"):
|
||||||
|
Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda m, u, h, b: (200, [], "") if u.endswith("/stacks/") else (404, None, "x")).resolve_stack_id("p-1", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cortecs_key_from_admin_env_counts_as_set_and_reaches_the_container(client, monkeypatch):
|
||||||
|
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": ""}) # kein DB-Wert (andere Tests setzen ihn)
|
||||||
|
with session() as db:
|
||||||
|
t = db.get(Tenant, "sprinz")
|
||||||
|
monkeypatch.delenv("CORTECS_API_KEY", raising=False)
|
||||||
|
assert "CORTECS_API_KEY" in deploy.missing_secrets(db, t)
|
||||||
|
monkeypatch.setenv("CORTECS_API_KEY", "ck-aus-env")
|
||||||
|
assert "CORTECS_API_KEY" not in deploy.missing_secrets(db, t)
|
||||||
|
assert deploy.build_env(db, t, reveal=True)["CORTECS_API_KEY"] == "ck-aus-env"
|
||||||
|
assert "ck-aus-env" not in str(deploy.build_env(db, t, reveal=False))
|
||||||
|
assert "aus Admin-Env" in deploy.build_env(db, t, reveal=False)["CORTECS_API_KEY"]
|
||||||
|
|||||||
Reference in New Issue
Block a user