diff --git a/app/auth.py b/app/auth.py index 3b24e41..b8b7124 100644 --- a/app/auth.py +++ b/app/auth.py @@ -298,3 +298,33 @@ def require_user(request: Request) -> dict[str, Any]: status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/portal/login"}, ) + + +# --- Role-based access control ---------------------------------------------- +# Portal roles, from least to most privileged: +# viewer – read-only: sees which services run and their documentation, nothing more. +# developer – everything a viewer sees + operational service workflows (Git/clone, deploy, +# variables, resources, metadata). Cannot delete services or access admin areas. +# admin – full access, including deleting services and the Administration / Users area. + +def is_admin(user: dict | None) -> bool: + return bool(user) and (user.get("role") or "").lower() == "admin" + + +def is_developer(user: dict | None) -> bool: + """True for developers and admins — the roles allowed to operate on services.""" + return bool(user) and (user.get("role") or "").lower() in ("admin", "developer") + + +def require_admin(request: Request) -> dict[str, Any]: + user = require_user(request) + if not is_admin(user): + raise HTTPException(status_code=403, detail="Tato akce je dostupná jen administrátorům.") + return user + + +def require_developer(request: Request) -> dict[str, Any]: + user = require_user(request) + if not is_developer(user): + raise HTTPException(status_code=403, detail="Tato akce je dostupná jen vývojářům a administrátorům.") + return user diff --git a/app/config.py b/app/config.py index e94611e..dbeff09 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,7 @@ DEPLOY_SCRIPT = "/tools/deploy-app.sh" DELETE_APP_SCRIPT = "/tools/delete-app.sh" BACKUP_SCRIPT = "/tools/backup-appfactory.sh" +RESTORE_SCRIPT = "/tools/restore-appfactory.sh" GENERATE_COMPOSE_SCRIPT = "/tools/generate-apps-compose.sh" CATALOG_FILE = "/opt/appfactory/apps/catalog.yml" diff --git a/app/main.py b/app/main.py index aad3124..73a2ccf 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_bool, read_env_value -from .routes import alerting, apps, audit, auth, backups, deployments, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, users, workers +from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, users, workers def create_app() -> FastAPI: @@ -23,8 +23,10 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(health.router) + app.include_router(catalog.router) app.include_router(auth.router) app.include_router(apps.router) + app.include_router(developers.router) app.include_router(backups.router) app.include_router(deployments.router) app.include_router(incidents.router) diff --git a/app/routes/apps.py b/app/routes/apps.py index 7517d5d..3a8fa23 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -5,7 +5,7 @@ from urllib.parse import quote, urlencode from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse -from ..auth import require_user +from ..auth import is_admin, is_developer, require_admin, require_developer, require_user from ..config import ( DEFAULT_GITEA_ORG, DELETE_APP_SCRIPT, @@ -300,6 +300,11 @@ def apps_page( gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG) host = get_appfactory_host(request.url.hostname or "") + # Role-based visibility: a viewer only sees which services run and their documentation. + # Developers also get Git/clone and operational actions; only admins see the delete button. + can_manage = is_developer(user) + can_delete = is_admin(user) + rows = "" for index, item in enumerate(apps, start=1): @@ -319,24 +324,10 @@ def apps_page( http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git") if gitea_url else "" ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git") if host else "" - rows += f""" - - -
- {health_dot} -
- {app_id}
- /apps/{app_id} -
-
- - {status} - Swagger - -
- Paměť: {memory_label} - CPU: {cpus_label} - + # Resources: developers/admins can edit; viewers see only the read-only summary. + if can_manage: + resource_edit = f""" +
@@ -355,9 +346,13 @@ def apps_page(
-
- - + """ + else: + resource_edit = "" + + # Git clone commands are an operational concern — visible to developers/admins only. + if can_manage and (http_clone or ssh_clone): + git_cell = f"""
Příkazy pro klonování @@ -371,13 +366,13 @@ def apps_page( -
- - -
- - - + """ + else: + git_cell = '' + + # Deploy actions for developers/admins; delete is admin-only. + manage_icons = ( + f""" @@ -385,13 +380,51 @@ def apps_page( - + """ + if can_manage + else "" + ) + delete_icon = ( + f"""
-
+ """ + if can_delete + else "" + ) + + rows += f""" + + +
+ {health_dot} +
+ {app_id}
+ /apps/{app_id} +
+
+ + {status} + Swagger + +
+ Paměť: {memory_label} + CPU: {cpus_label} + {resource_edit} + + + {git_cell} + + +
+ + + + {manage_icons} + {delete_icon}
@@ -445,33 +478,42 @@ def apps_page( if error: notice = f'

{html.escape(error)}

' + # Creating services and regenerating .env files are write actions — developers/admins only. + manage_toolbar = ( + """ +

Nová služba

+
+ +
+ """ + if can_manage + else "" + ) + return page( "Služby", f"""
-

Služby

+

Služby

Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.

-

Zálohy

-

Vytváření záloh a kopírování příkazů pro obnovu. Obnova je záměrně ruční a chráněná.

- Spravovat zálohy +

Zálohy

+

Vytváření, stažení a obnova záloh. Obnova je chráněná a dostupná jen administrátorům.

+ Spravovat zálohy
-

Nasazení

+

Nasazení

Historie posledních běhů nasazení, stavů a výstupů z deploy procesu.

- Zobrazit nasazení + Zobrazit nasazení

Nasazené služby

{notice} -

+ Nová služba

-
- -
+ {manage_toolbar}
@@ -480,8 +522,8 @@ def apps_page( - - Reset + + Reset
{pagination} @@ -489,7 +531,7 @@ def apps_page( - + @@ -503,7 +545,7 @@ def apps_page( @router.post("/apps/environment/apply-all") -def apply_all_app_environments_action(user=Depends(require_user)): +def apply_all_app_environments_action(user=Depends(require_developer)): try: message = apply_all_environments_message() except AppEnvironmentError as exc: @@ -524,6 +566,10 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" if not app: raise HTTPException(status_code=404, detail="App not found") + # Viewers may open the detail (services + docs are read-only here); editing the service, + # its variables or triggering a redeploy is reserved for developers/admins. + can_manage = is_developer(user) + log_audit_event( user, action="service.health.view", @@ -661,29 +707,36 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" if error: notice = f'

{html.escape(error)}

' - return page( - "Detail slu\u017eby", + # Editable sections are developer/admin only. Viewers keep the read-only Souhrn / Zdrav\u00ed / + # Historie cards below, which already present the service metadata without write access. + redeploy_block = ( f""" -
-

{escaped_app_id}

-

{name}

- {notice} -

- ← Zpět na služby - Nasazení služby - Úlohy služby -

-
+ -
+ """ + if can_manage + else "" + ) + detail_tabs = ( + """ + """ + if can_manage + else """ +
+ Historie +
+ """ + ) + metadata_card = ( + f"""

Metadata

@@ -728,7 +781,13 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
+ """ + if can_manage + else "" + ) + variables_card = ( + f"""

Proměnné

@@ -757,6 +816,31 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
+ """ + if can_manage + else "" + ) + + return page( + "Detail slu\u017eby", + f""" +
+

{escaped_app_id}

+

{name}

+ {notice} +

+ ← Zpět na služby + Nasazení služby + Úlohy služby +

+ {redeploy_block} +
+ + {detail_tabs} + + {metadata_card} + + {variables_card}

Souhrn

@@ -869,7 +953,7 @@ def save_app_metadata( container_port: str = Form(""), is_public: str | None = Form(None), is_enabled: str | None = Form(None), - user=Depends(require_user), + user=Depends(require_developer), ): app = get_app(app_id) if not app: @@ -911,7 +995,7 @@ def add_app_variable( key: str = Form(...), value: str = Form(""), is_secret: str | None = Form(None), - user=Depends(require_user), + user=Depends(require_developer), ): app = get_app(app_id) if not app: @@ -946,7 +1030,7 @@ def save_app_variable( key: str = Form(...), value: str = Form(""), is_secret: str | None = Form(None), - user=Depends(require_user), + user=Depends(require_developer), ): app = get_app(app_id) if not app: @@ -980,7 +1064,7 @@ def save_app_variable( @router.post("/apps/{app_id}/variables/{variable_id}/delete") -def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user)): +def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_developer)): app = get_app(app_id) if not app: raise HTTPException(status_code=404, detail="App not found") @@ -1007,7 +1091,7 @@ def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user @router.post("/apps/{app_id}/environment/apply") -def apply_app_environment_action(app_id: str, user=Depends(require_user)): +def apply_app_environment_action(app_id: str, user=Depends(require_developer)): app = get_app(app_id) if not app: raise HTTPException(status_code=404, detail="App not found") @@ -1028,7 +1112,7 @@ def apply_app_environment_action(app_id: str, user=Depends(require_user)): @router.post("/apps/{app_id}/redeploy") -def redeploy_app(app_id: str, user=Depends(require_user)): +def redeploy_app(app_id: str, user=Depends(require_developer)): app = get_app(app_id) if not app: raise HTTPException(status_code=404, detail="App not found") @@ -1062,7 +1146,7 @@ def redeploy_app(app_id: str, user=Depends(require_user)): @router.get("/new-app", response_class=HTMLResponse) -def new_app_form(request: Request, user=Depends(require_user)): +def new_app_form(request: Request, user=Depends(require_developer)): template_options = render_template_options(get_app_templates(create_enabled=True), "", include_blank=False) return page( "Nová služba", @@ -1114,7 +1198,7 @@ def create_app( owner: str = Form(""), memory: str = Form(""), cpus: str = Form(""), - user=Depends(require_user), + user=Depends(require_developer), ): selected_template = get_app_template(template, create_enabled=True) if not selected_template: @@ -1260,7 +1344,7 @@ def create_app( @router.post("/delete-app", response_class=HTMLResponse) -def delete_app(app_id: str = Form(...), user=Depends(require_user)): +def delete_app(app_id: str = Form(...), user=Depends(require_admin)): result = run_command([DELETE_APP_SCRIPT, app_id]) status = "OK" if result.returncode == 0 else "FAILED" log_audit_event( @@ -1288,7 +1372,7 @@ def update_resources( app_id: str = Form(...), memory: str = Form(""), cpus: str = Form(""), - user=Depends(require_user), + user=Depends(require_developer), ): memory = memory.strip() cpus = cpus.strip() diff --git a/app/routes/backups.py b/app/routes/backups.py index e0db1d3..b45d065 100644 --- a/app/routes/backups.py +++ b/app/routes/backups.py @@ -7,7 +7,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from ..auth import require_user from ..backups import backup_dir, is_backup_path, list_backups -from ..config import BACKUP_SCRIPT +from ..config import BACKUP_SCRIPT, RESTORE_SCRIPT from ..db.audit import log_audit_event from ..shell import run_command from ..templates.layout import page, render_result @@ -30,14 +30,24 @@ def backups_page(request: Request, user=Depends(require_user)): backup_path = html.escape(str(path), quote=True) backup_url_name = quote(path.name, safe="") download_action = ( - f'

Stáhnout

' + f'' + f' Stáhnout' if can_download else "" ) - - restore_cmd = html.escape( - f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}", - quote=True, + # Restore overwrites live data, so it is admin-only and guarded by an explicit confirm. + restore_action = ( + f""" +
+ + + + """ + if can_download + else "" ) rows += f""" @@ -49,42 +59,46 @@ def backups_page(request: Request, user=Depends(require_user)):
- """ if not rows: - rows = '' + rows = '' + + restore_hint = ( + "Obnovu lze spustit přímo z portálu tlačítkem Obnovit (pouze administrátor)." + if can_download + else "Obnovu zálohy může spustit pouze administrátor." + ) return page( "Zálohy", f"""
-

Správa záloh

+

Správa záloh

Zálohy neobsahují samotný adresář se zálohami. Obnova starší zálohy by neměla smazat novější soubory .tar.gz. - Obnova zůstává ruční, aby nedošlo k nechtěnému přepsání dat. + {restore_hint} Obnova přepíše aktuální data a je nevratná.

- +
@@ -94,7 +108,6 @@ def backups_page(request: Request, user=Depends(require_user)): - {rows} @@ -169,21 +182,38 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)): return RedirectResponse(url="/portal/backups", status_code=303) -@router.post("/backups/restore-trigger") -def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)): - target = Path(backup_path) +@router.post("/backups/restore", response_class=HTMLResponse) +def restore_backup(backup_path: str = Form(...), user=Depends(require_user)): + # Restoring overwrites live data and may restart services, so it is restricted to admins. + if not is_admin(user): + raise HTTPException(status_code=403, detail="Only admins can restore backups") + target = Path(backup_path) if not is_backup_path(target): return HTMLResponse("Neplatná cesta k záloze", status_code=400) + target = target.resolve() + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="Backup not found") + + result = run_command([RESTORE_SCRIPT, "--force", str(target)]) + status = "OK" if result.returncode == 0 else "FAILED" + log_audit_event( user, - action="restore_trigger", + action="backup_restore", target_type="backup", target_id=target.name, metadata={ "backup_path": str(target), + "status": status, + "returncode": result.returncode, }, ) - return RedirectResponse(url="/portal/backups", status_code=303) + return render_result( + title=f"Obnova zálohy: {status}", + back_url="/portal/backups", + sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], + user=user, + ) diff --git a/app/routes/catalog.py b/app/routes/catalog.py new file mode 100644 index 0000000..3e3f1fd --- /dev/null +++ b/app/routes/catalog.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter + +from ..catalog import load_apps + +router = APIRouter() + + +@router.get("/catalog") +def services_catalog(): + """Public, unauthenticated JSON catalog of deployed services. + + Returns each service's name and a link to its documentation. Intended to be exposed + publicly (the AppFactory reverse proxy maps the public ``/apps`` onto this endpoint). + The data comes from the generated catalog (catalog.yml) and contains no secrets. + """ + services = [] + for item in load_apps(): + app_id = item.get("id", "") + if not app_id: + continue + services.append({ + "name": item.get("name", app_id), + "docs": item.get("docs", f"/apps/{app_id}/docs"), + }) + + return {"services": services} diff --git a/app/routes/developers.py b/app/routes/developers.py new file mode 100644 index 0000000..4fe29db --- /dev/null +++ b/app/routes/developers.py @@ -0,0 +1,139 @@ +import html + +from fastapi import APIRouter, Depends + +from ..auth import require_user +from ..templates.layout import page + +router = APIRouter() + + +# Portal sections (the admin/agenda links shown in the navigation). role: minimum role that can +# actually use the section; viewers may see read-only data where a section allows it. +PORTAL_SECTIONS = [ + ("fa-server", "Služby", "/portal/apps", + "Přehled nasazených služeb: stav, dokumentace, prostředky (RAM/CPU), Git a akce.", "viewer (čtení) / developer / admin"), + ("fa-list-check", "Úlohy", "/portal/jobs", + "Fronta a historie úloh (nasazení, skripty) včetně stavů a logů.", "developer / admin"), + ("fa-calendar-days", "Plánované skripty", "/portal/scheduled-scripts", + "Cron-like skripty spouštěné na pozadí; úpravy jen pro administrátory.", "developer / admin"), + ("fa-bell", "Alerting", "/portal/alerting/rules", + "Pravidla alertů a jejich skripty; úpravy jen pro administrátory.", "developer / admin"), + ("fa-gauge-high", "Přehled", "/portal/operations", + "Operační přehled systému, běžící nasazení a stav služeb.", "admin"), + ("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness", + "Připravenost a deploy core služeb AppFactory.", "admin"), + ("fa-rocket", "Nasazení", "/portal/deployments", + "Historie deploy běhů, stavů a výstupů z deploy procesu.", "developer / admin"), + ("fa-triangle-exclamation", "Incidenty", "/portal/incidents", + "Evidované incidenty služeb.", "developer / admin"), + ("fa-gears", "Workery", "/portal/workers", + "Stav workerů zpracovávajících úlohy.", "admin"), + ("fa-users", "Users", "/portal/admin/users", + "Správa uživatelů a rolí (viewer / developer / admin).", "admin"), + ("fa-clipboard-list", "Audit", "/portal/audit", + "Auditní log akcí provedených v portálu.", "admin"), + ("fa-box-archive", "Zálohy", "/portal/backups", + "Vytváření, stažení a obnova záloh AppFactory. Obnova je jen pro administrátory.", "admin"), +] + +# Public / API endpoints not gated by the portal login. +PUBLIC_ENDPOINTS = [ + ("GET", "/apps", "Veřejný JSON katalog služeb (název + odkaz na dokumentaci). Routuje se přes Caddy."), + ("GET", "/portal/health", "Health endpoint portálu (HTTP 200, pokud portál běží)."), + ("GET", "/apps/<app-id>/docs", "Swagger/OpenAPI dokumentace konkrétní služby."), + ("GET", "/apps/<app-id>/health", "Health endpoint konkrétní služby."), +] + +# Roles model. +ROLES = [ + ("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání."), + ("developer", "Vše co viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace."), + ("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů, audit a admin-only operace."), +] + + +@router.get("/developers") +def developers_page(user=Depends(require_user)): + section_rows = "" + for icon, title, href, desc, roles in PORTAL_SECTIONS: + section_rows += f""" + + + + + + + """ + + endpoint_rows = "" + for method, path, desc in PUBLIC_ENDPOINTS: + endpoint_rows += f""" + + + + + + """ + + role_rows = "" + for role, desc in ROLES: + role_rows += f""" + + + + + """ + + return page( + "Pro vývojáře", + f""" +
+

Pro vývojáře

+

+ Přehled všech sekcí portálu, veřejných endpointů a rolí. Portál běží za reverzní proxy + na cestě /portal; jednotlivé služby na /apps/<app-id>. +

+
+ +
+

Sekce portálu (agendy)

+
Služba Status DokumentaceProstředky iProstředky Git Akce
- +
-
- - -
-
Obnova je nebezpečná a musí se spustit ručně přes SSH.
-
{download_action} + {restore_action}
- +
Nebyly nalezeny žádné zálohy.
Nebyly nalezeny žádné zálohy.
Záloha CestaPříkaz pro obnovu Akce
{html.escape(title)}{html.escape(href)}{html.escape(desc)}{html.escape(roles)}
{html.escape(method)}{path}{html.escape(desc)}
{html.escape(role)}{html.escape(desc)}
+ + + + + + + {section_rows} +
SekceOdkazPopisRole
+
+ +
+

Veřejné / API endpointy

+ + + + + + + {endpoint_rows} +
MetodaCestaPopis
+

+ Veřejný katalog /apps vrací JSON se seznamem služeb a odkazy na jejich dokumentaci. +

+
+ +
+

Role a oprávnění

+ + + + + + {role_rows} +
RoleCo může
+
+ """, + user=user, + ) diff --git a/app/routes/users.py b/app/routes/users.py index 290fb88..cb1d545 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -45,9 +45,9 @@ def render_sync_pill(status: str) -> str: def render_role_overview() -> str: role_rows = ( - ("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."), - ("developer", "Operational portal access for application and service workflows. No access to Administration / Users."), - ("viewer", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."), + ("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů a rolí, admin-only operace."), + ("developer", "Vidí běžící služby a dokumentaci jako viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace / Uživatelů."), + ("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání. Bez přístupu do Administrace / Uživatelů. Výchozí role pro Google přihlášení."), ) rows = "" for role, description in role_rows: diff --git a/app/static/styles.css b/app/static/styles.css index 85bbb2b..eed7937 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -436,19 +436,72 @@ button:hover, } .info-dot { + position: relative; display: inline-flex; width: 18px; height: 18px; margin-left: 4px; align-items: center; justify-content: center; - border: 1px solid var(--muted); - border-radius: 999px; color: var(--muted); - font-size: 12px; - font-weight: 800; + font-size: 13px; line-height: 1; text-transform: none; + cursor: help; +} + +.info-dot:hover, +.info-dot:focus-visible { + color: var(--secondary-dark); + outline: none; +} + +/* Custom tooltip bubble — the native title attribute is slow and easy to miss, so info hints + render as an immediate, styled popover on hover/focus. */ +.info-dot[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + z-index: 20; + width: max-content; + max-width: 260px; + padding: 8px 10px; + border-radius: 8px; + background: #1f2937; + color: #fff; + font-size: 12px; + font-weight: 400; + line-height: 1.4; + text-align: left; + white-space: normal; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22); + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease; +} + +.info-dot[data-tooltip]::before { + content: ""; + position: absolute; + top: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + z-index: 20; + border: 6px solid transparent; + border-bottom-color: #1f2937; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease; +} + +.info-dot[data-tooltip]:hover::after, +.info-dot[data-tooltip]:focus-visible::after, +.info-dot[data-tooltip]:hover::before, +.info-dot[data-tooltip]:focus-visible::before { + opacity: 1; + visibility: visible; } input, diff --git a/app/templates/layout.py b/app/templates/layout.py index 515d87c..56576ed 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -16,29 +16,30 @@ def page(title: str, body: str, user=None) -> str: if (user.get("role") or "").lower() == "admin": admin_menu = """ """ nav = f""" """ @@ -47,7 +48,7 @@ def page(title: str, body: str, user=None) -> str: {display_name} @{username}
- +
"""