From a948324b2e6fa6f1f8007bc6a4bf73e1a03e8600 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:08:31 +0200 Subject: [PATCH] dev fix, ip controls --- app/db/apps.py | 90 ++++++++++++++++++ app/db/migrations.py | 16 ++++ app/routes/apps.py | 192 +++++++++++++++++++++++++++++++++++++++ app/routes/developers.py | 3 +- app/static/styles.css | 10 ++ 5 files changed, 310 insertions(+), 1 deletion(-) diff --git a/app/db/apps.py b/app/db/apps.py index 0b67654..5dbb137 100644 --- a/app/db/apps.py +++ b/app/db/apps.py @@ -408,6 +408,96 @@ def delete_app_variable(variable_id: int, app_id: str): con.close() +def get_app_ip_access_rules(app_id: str): + run_migrations() + con = get_connection() + + rows = con.execute( + """ + SELECT id, app_id, ip_cidr, methods, description, + COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at + FROM app_ip_access_rules + WHERE app_id = ? + ORDER BY id + """, + (app_id,), + ).fetchall() + + con.close() + return [dict(row) for row in rows] + + +def get_app_ip_access_rule(rule_id: int, app_id: str): + run_migrations() + con = get_connection() + + row = con.execute( + """ + SELECT id, app_id, ip_cidr, methods, description, + COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at + FROM app_ip_access_rules + WHERE id = ? AND app_id = ? + """, + (rule_id, app_id), + ).fetchone() + + con.close() + return dict(row) if row else None + + +def create_app_ip_access_rule(app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool): + run_migrations() + con = get_connection() + + con.execute( + """ + INSERT INTO app_ip_access_rules (app_id, ip_cidr, methods, description, is_enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + (app_id, ip_cidr, methods, description, 1 if is_enabled else 0), + ) + + con.commit() + con.close() + + +def update_app_ip_access_rule(rule_id: int, app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool): + run_migrations() + con = get_connection() + + con.execute( + """ + UPDATE app_ip_access_rules + SET ip_cidr = ?, + methods = ?, + description = ?, + is_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND app_id = ? + """, + (ip_cidr, methods, description, 1 if is_enabled else 0, rule_id, app_id), + ) + + con.commit() + con.close() + + +def delete_app_ip_access_rule(rule_id: int, app_id: str): + run_migrations() + con = get_connection() + + con.execute( + """ + DELETE FROM app_ip_access_rules + WHERE id = ? AND app_id = ? + """, + (rule_id, app_id), + ) + + con.commit() + con.close() + + def update_app_resources(app_id: str, memory: str, cpus: str): run_migrations() con = get_connection() diff --git a/app/db/migrations.py b/app/db/migrations.py index c844f0c..bd7518e 100644 --- a/app/db/migrations.py +++ b/app/db/migrations.py @@ -267,5 +267,21 @@ def run_migrations(): con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_created ON alert_events(created_at)") con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_job_id ON alert_events(job_id)") + con.execute( + """ + CREATE TABLE IF NOT EXISTS app_ip_access_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id TEXT NOT NULL, + ip_cidr TEXT NOT NULL, + methods TEXT NOT NULL DEFAULT 'WRITE', + description TEXT, + is_enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_app_id ON app_ip_access_rules(app_id)") + con.commit() con.close() diff --git a/app/routes/apps.py b/app/routes/apps.py index 3a8fa23..a302d53 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -16,14 +16,19 @@ from ..config import ( read_env_value, ) from ..db.apps import ( + create_app_ip_access_rule, create_app_variable, + delete_app_ip_access_rule, delete_app_variable, get_app, + get_app_ip_access_rule, + get_app_ip_access_rules, get_app_template, get_app_deployments, get_app_templates, get_app_variables, get_apps, + update_app_ip_access_rule, update_app_metadata, update_app_resources, upsert_created_app, @@ -41,6 +46,20 @@ from ..templates.layout import page, render_result router = APIRouter() DEFAULT_PAGE_SIZE = 20 + +# Selectable method presets for IP access rules. WRITE = POST,PUT,PATCH,DELETE; ALL = every +# common HTTP method including GET. The Caddy generator will later interpret these values. +IP_ACCESS_METHOD_OPTIONS = ( + "WRITE", + "ALL", + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "GET,POST", + "POST,PATCH,DELETE", +) METADATA_FIELDS = ( "name", "description", @@ -605,6 +624,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" template_value = render_template_label(templates, app.get("template", "") or "") environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw) variables = get_app_variables(app.get("id", "")) + ip_access_rules = get_app_ip_access_rules(app.get("id", "")) incidents = get_service_incidents(app.get("id", ""), limit=20) current_health = get_service_health(app.get("id", "")) health_history = get_service_health_history(app.get("id", ""), limit=50) @@ -701,6 +721,48 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" if not variable_rows: variable_rows = 'Zatím nejsou evidované žádné proměnné.' + def render_method_options(selected: str) -> str: + selected_upper = (selected or "").upper() + options = list(IP_ACCESS_METHOD_OPTIONS) + if selected_upper and selected_upper not in options: + options.append(selected_upper) + out = "" + for opt in options: + is_sel = " selected" if opt == selected_upper else "" + out += f'' + return out + + # IP access rules — inputs in each column are associated with the per-row update form via the + # HTML5 form="..." attribute, so the whole row is editable inline while staying valid markup. + ip_access_rule_rows = "" + for rule in ip_access_rules: + rid = html.escape(str(rule.get("id", ""))) + form_id = f"iprule-{rid}" + rule_ip = html.escape(rule.get("ip_cidr", "") or "", quote=True) + rule_desc = html.escape(rule.get("description", "") or "", quote=True) + rule_enabled = bool(rule.get("is_enabled", 1)) + row_class = "" if rule_enabled else " rule-disabled" + enabled_checked = bool_checked(rule_enabled) + ip_label = html.escape(rule.get("ip_cidr", "") or "") + ip_access_rule_rows += f""" + + + + + + +
+ +
+ +
+ + + """ + + if not ip_access_rule_rows: + ip_access_rule_rows = 'Zatím nejsou evidovaná žádná IP access pravidla.' + notice = "" if message: notice = f'

{html.escape(message)}

' @@ -724,6 +786,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
Metadata Proměnné + Security / IP Access Historie
""" @@ -821,6 +884,45 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" else "" ) + ip_access_card = ( + f""" +
+

Security / IP Access

+

+ Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody. + WRITE = POST, PUT, PATCH, DELETE; ALL = všechny běžné metody včetně GET. + Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později). +

+ + + + + + + + + {ip_access_rule_rows} +
IP/CIDRMethodsDescriptionEnabledActions
+ +
+

Přidat pravidlo

+ + + + + + + +
+ +
+
+
+ """ + if can_manage + else "" + ) + return page( "Detail slu\u017eby", f""" @@ -842,6 +944,8 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" {variables_card} + {ip_access_card} +

Souhrn

@@ -1111,6 +1215,94 @@ def apply_app_environment_action(app_id: str, user=Depends(require_developer)): return redirect_app_detail(app_id, anchor="promenne", message=message) +@router.post("/apps/{app_id}/ip-access-rules") +def add_app_ip_access_rule( + app_id: str, + ip_cidr: str = Form(...), + methods: str = Form("WRITE"), + description: str = Form(""), + is_enabled: str | None = Form(None), + user=Depends(require_developer), +): + app = get_app(app_id) + if not app: + raise HTTPException(status_code=404, detail="App not found") + + rule_ip = clean_optional(ip_cidr) + rule_methods = clean_optional(methods).upper() + if not rule_ip: + return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.") + if not rule_methods: + return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.") + + create_app_ip_access_rule(app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled)) + log_audit_event( + user, + action="app.ip_access_rule.created", + target_type="app", + target_id=app_id, + metadata={"ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)}, + ) + return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo přidáno.") + + +@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/update") +def save_app_ip_access_rule( + app_id: str, + rule_id: int, + ip_cidr: str = Form(...), + methods: str = Form("WRITE"), + description: str = Form(""), + is_enabled: str | None = Form(None), + user=Depends(require_developer), +): + app = get_app(app_id) + if not app: + raise HTTPException(status_code=404, detail="App not found") + + existing = get_app_ip_access_rule(rule_id, app_id) + if not existing: + return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.") + + rule_ip = clean_optional(ip_cidr) + rule_methods = clean_optional(methods).upper() + if not rule_ip: + return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.") + if not rule_methods: + return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.") + + update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled)) + log_audit_event( + user, + action="app.ip_access_rule.updated", + target_type="app", + target_id=app_id, + metadata={"rule_id": rule_id, "ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)}, + ) + return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo upraveno.") + + +@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/delete") +def remove_app_ip_access_rule(app_id: str, rule_id: int, user=Depends(require_developer)): + app = get_app(app_id) + if not app: + raise HTTPException(status_code=404, detail="App not found") + + existing = get_app_ip_access_rule(rule_id, app_id) + if not existing: + return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.") + + delete_app_ip_access_rule(rule_id, app_id) + log_audit_event( + user, + action="app.ip_access_rule.deleted", + target_type="app", + target_id=app_id, + metadata={"rule_id": rule_id, "ip_cidr": existing.get("ip_cidr"), "methods": existing.get("methods")}, + ) + return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo smazáno.") + + @router.post("/apps/{app_id}/redeploy") def redeploy_app(app_id: str, user=Depends(require_developer)): app = get_app(app_id) diff --git a/app/routes/developers.py b/app/routes/developers.py index 4fe29db..aef4b48 100644 --- a/app/routes/developers.py +++ b/app/routes/developers.py @@ -1,6 +1,7 @@ import html from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse from ..auth import require_user from ..templates.layout import page @@ -53,7 +54,7 @@ ROLES = [ ] -@router.get("/developers") +@router.get("/developers", response_class=HTMLResponse) def developers_page(user=Depends(require_user)): section_rows = "" for icon, title, href, desc, roles in PORTAL_SECTIONS: diff --git a/app/static/styles.css b/app/static/styles.css index eed7937..0640189 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -435,6 +435,16 @@ button:hover, background: var(--muted); } +/* Disabled IP access rules are dimmed so active/inactive rules are visually distinct. */ +.ip-rule-row.rule-disabled { + opacity: 0.55; +} + +.ip-rule-row.rule-disabled input, +.ip-rule-row.rule-disabled select { + background: #f3f5f7; +} + .info-dot { position: relative; display: inline-flex;