From cee40534b238504cd0d407298617833859ff102d Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 12 May 2026 13:04:42 +0300 Subject: [PATCH 1/3] feat(release): prepare v0.2.8 service secret support Add deployable service/app target support for remote secrets, expose the remote secret lifecycle through MCP, and harden MCP tool execution with per-tool Casbin CALL policies plus MFA for sensitive operations. Update release docs, README, DockerHub notes, MCP integration docs, and website copy for v0.2.8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- BUILD_RELEASE.md | 8 +- CHANGELOG.md | 43 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- DOCKERHUB.md | 14 +- README.md | 24 +- docs/MCP_SERVER_BACKEND_PLAN.md | 9 + docs/MCP_SERVER_FRONTEND_INTEGRATION.md | 35 +- docs/STACKER_YML_REFERENCE.md | 33 +- ...18_casbin_mcp_remote_secret_tools.down.sql | 101 ++++ ...0018_casbin_mcp_remote_secret_tools.up.sql | 99 ++++ src/connectors/admin_service/jwt.rs | 1 + src/console/commands/appclient/new.rs | 1 + src/forms/user.rs | 6 + src/mcp/registry.rs | 211 +++++++- src/mcp/tools/agent_control.rs | 2 +- src/mcp/tools/mod.rs | 2 + src/mcp/tools/proxy.rs | 6 +- src/mcp/tools/remote_secrets.rs | 451 ++++++++++++++++++ src/mcp/websocket.rs | 23 +- .../authentication/method/f_agent.rs | 1 + .../authentication/method/f_cookie.rs | 4 +- .../authentication/method/f_oauth.rs | 5 +- .../authentication/method/f_query.rs | 2 +- src/models/user.rs | 115 +++++ src/routes/handoff/mod.rs | 1 + src/startup.rs | 4 +- website/dist/index.html | 51 +- website/src/index.html | 51 +- 29 files changed, 1220 insertions(+), 87 deletions(-) create mode 100644 migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql create mode 100644 migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql create mode 100644 src/mcp/tools/remote_secrets.rs diff --git a/BUILD_RELEASE.md b/BUILD_RELEASE.md index 59376574..d714b296 100644 --- a/BUILD_RELEASE.md +++ b/BUILD_RELEASE.md @@ -18,18 +18,18 @@ git pull ### 2) Create and publish the release ```bash -gh release create v0.2.7 --generate-notes +gh release create v0.2.8 --generate-notes ``` -This creates the `v0.2.7` tag and publishes the release, which triggers: +This creates the `v0.2.8` tag and publishes the release, which triggers: - CLI binary builds (linux + macOS) and uploads to the release. -- Docker image build and push tagged as `trydirect/stacker:v0.2.7`. +- Docker image build and push tagged as `trydirect/stacker:v0.2.8`. ### 3) Verify artifacts ```bash -gh release view v0.2.7 --json assets --jq '.assets[].name' +gh release view v0.2.8 --json assets --jq '.assets[].name' ``` ### 4) Check workflows diff --git a/CHANGELOG.md b/CHANGELOG.md index ef285305..5358d469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.2.8] — 2026-05-12 + +### Added — Remote service/app target secrets + +- `stacker secrets set --scope service --service ` now supports real + deployable service/app targets, not only the main app code. Valid targets are + discovered with `stacker secrets apps`. +- Stacker.yml `services:` entries are synced as service targets while the main + `app:` remains the web target, so remote secrets can be scoped to support + services such as `upload`, `worker`, or `postgres`. +- Image-backed services from `deploy.compose_file` are registered as service + targets during cloud/server deploy preparation when they can be represented + safely; build-only and platform-managed services are skipped with warnings. +- Service-scoped remote secrets remain isolated per target and are rendered only + into the matching service; metadata APIs and CLI output still never return + plaintext Vault values. +- CLI help and errors now use "deployable service/app target" wording and list + available targets when an unknown service code is requested. + +### Added — MCP remote service secret tools + +- Added MCP tools for the remote service secret lifecycle: + `list_remote_secret_targets`, `list_remote_service_secrets`, + `get_remote_service_secret`, `set_remote_service_secret`, and + `delete_remote_service_secret`. +- MCP remote secret reads are metadata-only, match the CLI/API target model, and + write secret values directly to Vault without returning plaintext values. +- All MCP tool calls now require explicit per-tool Casbin `CALL` permission under + `/mcp/tools/` before the handler executes; marketplace admin tools + are granted only to `group_admin`. +- Sensitive MCP write/destructive operations, including remote secret writes and + deletes, additionally require a token or auth profile with verified 2FA/MFA + before Vault or deployment state is touched. + ### Added — Cloud provider firewall operations - Added `stacker cloud firewall add|remove|list` for cloud-provider firewall @@ -59,6 +93,15 @@ All notable changes to this project will be documented in this file. provider firewalls receive app ports such as Coolify's `8000:8080` instead of the generic custom-app fallback `8080`. +### Fixed — Deployment IP persistence on paused/failed installs + +- Cloud/server deploy status handling now extracts a server IP from installer + progress messages such as `178.104.222.170: Copy files is done` when the + structured server record has not populated `srv_ip` yet. +- The CLI saves that fallback IP into the local deployment context, and the MQ + listener persists the IP server-side so paused or failed deployments still + retain a usable host address for SSH repair and retry workflows. + ### Changed — Agent proxy SSL control - `stacker agent configure-proxy` now supports `--no-ssl` to create or update a diff --git a/Cargo.lock b/Cargo.lock index f4c502e6..b3586fbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6282,7 +6282,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.2.7" +version = "0.2.8" dependencies = [ "actix", "actix-casbin-auth", diff --git a/Cargo.toml b/Cargo.toml index 1fa9ca93..ec3d6024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stacker" -version = "0.2.7" +version = "0.2.8" edition = "2021" default-run= "server" diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 412b570f..a805fdbc 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -1,7 +1,7 @@ # Stacker — Build, Deploy & Manage Containerised Apps [![Discord](https://img.shields.io/discord/578119430391988232?label=discord&logo=discord&color=5865F2)](https://discord.gg/mNhsa8VdYX) -[![Version](https://img.shields.io/badge/version-0.2.7-blue)](https://github.com/trydirect/stacker/releases) +[![Version](https://img.shields.io/badge/version-0.2.8-blue)](https://github.com/trydirect/stacker/releases) [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/trydirect/stacker/blob/main/LICENSE) [![GitHub](https://img.shields.io/badge/source-GitHub-181717?logo=github)](https://github.com/trydirect/stacker) @@ -16,7 +16,7 @@ │ Stacker CLI │────────▶│ Stacker Server │────────▶│ Status Panel Agent │ │ │ REST │ │ queue │ (on target server) │ │ stacker.yml │ API │ Stack Builder UI│ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◀────────│ health / logs / │ +│ init/deploy │ │ 85+ MCP tools │◀────────│ health / logs / │ │ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ │ └─────────────────────┘ @@ -123,12 +123,12 @@ The `trydirect/stacker` image contains the **Stacker Server** — a Rust-built b - Role-based access control (Casbin) ### MCP Server (Model Context Protocol) -48+ tools exposed over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: +85+ tools exposed over WebSocket, enabling AI agents (Claude, GPT, etc.) to manage infrastructure programmatically: - Project & deployment management - Container operations (start, stop, restart, exec) - Log analysis & error summaries -- Vault config read/write -- Proxy configuration +- Vault config and remote service secret management +- Proxy and firewall configuration - Server resource monitoring - Docker Compose generation & preview @@ -156,6 +156,8 @@ The CLI (`stacker-cli`) is a standalone binary — no server required for local | `stacker deploy` | Build & deploy the stack (local, cloud, or server) | | `stacker status` | Show running containers and health | | `stacker logs` | View container logs (`--follow`, `--service`, `--tail`) | +| `stacker secrets` | Manage local `.env` secrets and remote Vault-backed service/server secrets | +| `stacker cloud firewall` | Manage provider firewall rules without SSH | | `stacker destroy` | Tear down the deployed stack | | `stacker ai ask` | Ask AI about your stack, or let it modify config | | `stacker service add` | Add from 20+ built-in service templates | @@ -286,7 +288,7 @@ Stacker auto-detects and generates optimised multi-stage Dockerfiles for: | Tag | Description | |-----|-------------| | `latest` | Latest stable release | -| `x.y.z` | Specific version (e.g. `0.2.7`) | +| `x.y.z` | Specific version (e.g. `0.2.8`) | | `test` | Development/testing builds | --- diff --git a/README.md b/README.md index ca16a44b..9b2ba636 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
Discord -Version +Version License

@@ -13,6 +13,12 @@ Stacker is a platform for turning any project into a deployable Docker stack. Add a `stacker.yml` to your repo, and Stacker generates Dockerfiles, docker-compose definitions, reverse-proxy configs, and deploys locally or to cloud providers — optionally with AI assistance. +**v0.2.8 highlights:** remote Vault-backed secrets now work for deployable +service/app targets from `stacker.yml` and supported Compose services, paused or +failed cloud/server installs retain discovered IP addresses, cloud-provider +firewalls can be managed without SSH, and MCP now exposes remote service secret +tools. + ## Quick Start @@ -111,7 +117,7 @@ Full schema reference: [docs/STACKER_YML_REFERENCE.md](docs/STACKER_YML_REFERENC │ Stacker CLI │────────►│ Stacker Server │────────►│ Status Panel Agent │ │ │ REST │ │ queue │ (on target server) │ │ stacker.yml │ API │ Stack Builder UI│ pull │ │ -│ init/deploy │ │ 48+ MCP tools │◄────────│ health / logs / │ +│ init/deploy │ │ 85+ MCP tools │◄────────│ health / logs / │ │ status/logs │ │ Vault · AMQP │ HMAC │ restart / exec / │ └──────────────┘ └──────────────────┘ │ deploy_app / proxy │ │ └─────────────────────┘ @@ -204,7 +210,10 @@ authorizes its public key on the server when possible. The CLI prints a normal # Local project .env secret stacker secrets set DB_PASSWORD=supersecret -# Remote service secret used at render/deploy time for one app +# Discover valid remote deployable service/app targets first +stacker secrets apps + +# Remote service secret used at render/deploy time for one target stacker secrets set S3_SECRET_KEY \ --scope service \ --service uploader \ @@ -220,14 +229,13 @@ stacker secrets set NPM_TOKEN \ stacker secrets list --scope service --service uploader --json stacker secrets get S3_SECRET_KEY --scope service --service uploader --json -# Discover valid remote service app codes for the current stacker.yml project -stacker secrets apps ``` - Local mode remains the default and reads/writes the project `.env` file. - Remote mode is enabled only with `--scope service` or `--scope server`. - Service-scoped remote commands default `--project` from `stacker.yml -> project.identity`; `--project` still overrides it explicitly. -- Service-scoped secrets are merged into rendered app env at deploy time. +- Service-scoped secrets target deployable service/app codes listed by `stacker secrets apps`, including registered `stacker.yml` services and supported image-backed Compose services after a deploy/update sync. +- Service-scoped secrets are merged only into the matching rendered service/app env at deploy time. - Remote `get` and `list` do **not** return plaintext values in v1. ### Marketplace workflow (for stack developers) @@ -255,6 +263,7 @@ curl -sL https://marketplace.try.direct//install.sh | sh - **Auto-detection** — identifies Node, Python, Rust, Go, PHP, static sites from project files - **Dockerfile generation** — produces optimised multi-stage Dockerfiles per app type - **Docker Compose generation** — wires app + services + proxy + monitoring +- **Remote service secrets** — Vault-backed service/app target secrets are metadata-only when read and isolated to the selected service - **AI-assisted config** — scans project, calls LLM to generate tailored `stacker.yml` - **AI troubleshooting** — on deploy failure, suggests fixes via AI or deterministic fallback hints - **Service catalog** — 20+ built-in service templates (Postgres, Redis, WordPress, etc.) — add with `stacker service add` @@ -263,7 +272,8 @@ curl -sL https://marketplace.try.direct//install.sh | sh - **SSH key management** — generate, view, upload, and repair server SSH keys (Vault-backed), with automatic local backup SSH access after cloud deploy - **Reverse proxy** — auto-detects Nginx / Nginx Proxy Manager, configures domains + SSL -- **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode +- **Cloud deployment** — Hetzner, DigitalOcean, AWS, Linode, with provider firewall operations and paused/failed install IP retention +- **MCP Server** — 85+ tools, including deployment, agent control, config, proxy, firewall, and remote service secret management - **Marketplace** — submit stacks for review, auto-publish on approval, check status from CLI - **Buyer install** — purchase tokens, one-liner install scripts, agent self-registration diff --git a/docs/MCP_SERVER_BACKEND_PLAN.md b/docs/MCP_SERVER_BACKEND_PLAN.md index d78db97f..aaaded95 100644 --- a/docs/MCP_SERVER_BACKEND_PLAN.md +++ b/docs/MCP_SERVER_BACKEND_PLAN.md @@ -3,6 +3,15 @@ ## Overview This document outlines the implementation plan for adding Model Context Protocol (MCP) server capabilities to the Stacker backend. The MCP server will expose Stacker's functionality as tools that AI assistants can use to help users build and deploy application stacks. +> **Current status:** The original 17-tool MVP has been surpassed. As of +> v0.2.8 the registry exposes 85+ tools, including remote service secret +> management (`list_remote_secret_targets`, `list_remote_service_secrets`, +> `get_remote_service_secret`, `set_remote_service_secret`, +> `delete_remote_service_secret`) with metadata-only reads and Vault-backed +> writes. All tool calls require explicit per-tool Casbin `CALL` policies under +> `/mcp/tools/`; sensitive write/destructive tools additionally +> require verified 2FA/MFA. + ## Architecture ``` diff --git a/docs/MCP_SERVER_FRONTEND_INTEGRATION.md b/docs/MCP_SERVER_FRONTEND_INTEGRATION.md index c23eda7d..824f1fb3 100644 --- a/docs/MCP_SERVER_FRONTEND_INTEGRATION.md +++ b/docs/MCP_SERVER_FRONTEND_INTEGRATION.md @@ -30,12 +30,45 @@ This document provides comprehensive guidance for integrating the Stacker MCP (M ▼ ┌──────────────────────────────────────────────────────────────┐ │ Stacker Backend (MCP Server) │ -│ - Tool Registry (17+ tools) │ +│ - Tool Registry (85+ tools) │ │ - Session Management │ │ - OAuth Authentication │ └──────────────────────────────────────────────────────────────┘ ``` +## Current v0.2.8 Tool Coverage + +The MCP server now exposes project/deployment, cloud credential discovery, +container operations, Status Panel agent control, proxy configuration, guest OS +firewall tools, Vault config tools, and remote service secret tools. The remote +secret tools mirror the CLI/API target model: + +- `list_remote_secret_targets` — list deployable service/app target codes for a + project. +- `list_remote_service_secrets` — list metadata for Vault-backed service-scope + secrets on one target. +- `get_remote_service_secret` — read metadata for one service secret. +- `set_remote_service_secret` — write one service secret value to Vault. +- `delete_remote_service_secret` — delete one service secret. + +Remote secret reads are metadata-only; plaintext values are written to Vault but +never returned to MCP clients. + +Every MCP tool call is checked against Casbin before its handler executes. Clients +must have a `CALL` policy for `/mcp/tools/`. Marketplace admin tools +are granted only to `group_admin`; regular project, deployment, cloud, +container, proxy, firewall, Vault, and remote-secret tools use the normal user +group policies plus their existing project/ownership checks. + +`set_remote_service_secret` and `delete_remote_service_secret` are sensitive +write operations. They also require: + +- Casbin permission for `/mcp/tools/set_remote_service_secret` or + `/mcp/tools/delete_remote_service_secret` with action `CALL`. +- A verified 2FA/MFA marker from the authenticated user profile or access token + (`mfa_verified`, `two_factor_verified`, `amr` containing `totp`, `otp`, + `webauthn`, etc.). + ## Technology Stack ### Core Dependencies diff --git a/docs/STACKER_YML_REFERENCE.md b/docs/STACKER_YML_REFERENCE.md index 3de78c44..3d14e07b 100644 --- a/docs/STACKER_YML_REFERENCE.md +++ b/docs/STACKER_YML_REFERENCE.md @@ -954,6 +954,7 @@ Configuration issues: | `stacker deploy` | Build and deploy the stack; cloud deploys also install a local SSH backup key when possible | | `stacker status` | Show container status | | `stacker logs` | Show container logs | +| `stacker secrets` | Manage local `.env` secrets or remote Vault-backed service/server secrets | | `stacker destroy` | Tear down the stack | | `stacker config validate` | Validate `stacker.yml` | | `stacker config show` | Display resolved configuration | @@ -962,6 +963,9 @@ Configuration issues: | `stacker ai ask` | Ask the AI assistant a question | | `stacker proxy add` | Add a reverse-proxy domain entry | | `stacker proxy detect` | Detect running reverse proxies | +| `stacker cloud firewall add` | Open cloud-provider firewall ports without SSH | +| `stacker cloud firewall remove` | Remove Stacker-managed cloud-provider firewall rules | +| `stacker cloud firewall list` | List cloud-provider firewall rules for a server | | `stacker ssh-key generate` | Generate a Vault-backed SSH key pair for a server | | `stacker ssh-key show` | Display the public SSH key for a server | | `stacker ssh-key upload` | Upload an existing SSH key pair for a server | @@ -974,7 +978,8 @@ Configuration issues: | `stacker agent restart ` | Restart a container via the agent | | `stacker agent deploy-app` | Deploy or update an app container on the target server | | `stacker agent remove-app` | Remove an app container (optional volume/image cleanup) | -| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent | +| `stacker agent configure-proxy` | Configure Nginx Proxy Manager via the agent; use `--no-ssl` for plain HTTP hosts | +| `stacker agent configure-firewall` | Configure guest OS firewall rules via the Status Panel agent | | `stacker agent history` | Show recent agent command execution history | | `stacker agent exec` | Execute a raw agent command with JSON parameters | | `stacker update` | Check for CLI updates | @@ -1034,6 +1039,32 @@ stacker deploy --force-rebuild # Force regenerate .stacker/ artifacts > local backup key in the user-scoped Stacker config directory and authorizes its > public key on the server when possible. It prints a copy-paste-ready `ssh -i` > command; the Vault private key is not exported to the CLI. +> **IP persistence:** If a cloud/server install pauses or fails after the +> installer has reported an IP address, Stacker saves that discovered IP in the +> local deployment context and persists it server-side when possible. + +### Remote secrets + +```bash +# Discover deployable service/app targets for the current project +stacker secrets apps + +# Store a Vault-backed secret for one service/app target +stacker secrets set S3_BUCKET \ + --scope service \ + --service upload \ + --body superbucket + +# Remote reads return metadata only, never plaintext values +stacker secrets list --scope service --service upload --json +stacker secrets get S3_BUCKET --scope service --service upload --json +``` + +Service-scoped remote secrets target the codes listed by `stacker secrets apps`. +Those codes include the main app, registered `stacker.yml` services, and +supported image-backed services extracted from `deploy.compose_file` during +cloud/server deploy preparation. A service secret is rendered only into the +matching service/app target. ### Other commands diff --git a/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql b/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql new file mode 100644 index 00000000..a67844e9 --- /dev/null +++ b/migrations/20260717120018_casbin_mcp_remote_secret_tools.down.sql @@ -0,0 +1,101 @@ +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'list_projects'), + ('group_user', 'get_project'), + ('group_user', 'create_project'), + ('group_user', 'create_project_app'), + ('group_user', 'suggest_resources'), + ('group_user', 'list_templates'), + ('group_user', 'validate_domain'), + ('group_user', 'get_deployment_status'), + ('group_user', 'start_deployment'), + ('group_user', 'cancel_deployment'), + ('group_user', 'list_clouds'), + ('group_user', 'get_cloud'), + ('group_user', 'add_cloud'), + ('group_user', 'delete_cloud'), + ('group_user', 'list_cloud_regions'), + ('group_user', 'list_cloud_server_sizes'), + ('group_user', 'list_cloud_images'), + ('group_user', 'delete_project'), + ('group_user', 'clone_project'), + ('group_user', 'get_user_profile'), + ('group_user', 'get_subscription_plan'), + ('group_user', 'list_installations'), + ('group_user', 'get_installation_details'), + ('group_user', 'search_applications'), + ('group_user', 'search_marketplace_templates'), + ('group_user', 'get_notifications'), + ('group_user', 'mark_notification_read'), + ('group_user', 'mark_all_notifications_read'), + ('group_user', 'initiate_deployment'), + ('group_user', 'trigger_redeploy'), + ('group_user', 'add_app_to_deployment'), + ('group_user', 'get_container_logs'), + ('group_user', 'get_container_health'), + ('group_user', 'list_containers'), + ('group_user', 'restart_container'), + ('group_user', 'diagnose_deployment'), + ('group_user', 'escalate_to_support'), + ('group_user', 'get_live_chat_info'), + ('group_user', 'stop_container'), + ('group_user', 'start_container'), + ('group_user', 'get_error_summary'), + ('group_user', 'get_app_env_vars'), + ('group_user', 'set_app_env_var'), + ('group_user', 'delete_app_env_var'), + ('group_user', 'get_app_config'), + ('group_user', 'update_app_ports'), + ('group_user', 'update_app_domain'), + ('group_user', 'preview_install_config'), + ('group_user', 'get_ansible_role_defaults'), + ('group_user', 'render_ansible_template'), + ('group_user', 'validate_stack_config'), + ('group_user', 'discover_stack_services'), + ('group_user', 'get_vault_config'), + ('group_user', 'set_vault_config'), + ('group_user', 'list_vault_configs'), + ('group_user', 'apply_vault_config'), + ('group_user', 'configure_proxy'), + ('group_user', 'delete_proxy'), + ('group_user', 'list_proxies'), + ('group_user', 'list_project_apps'), + ('group_user', 'get_deployment_resources'), + ('group_user', 'list_remote_secret_targets'), + ('group_user', 'list_remote_service_secrets'), + ('group_user', 'get_remote_service_secret'), + ('group_user', 'set_remote_service_secret'), + ('group_user', 'delete_remote_service_secret'), + ('group_user', 'get_docker_compose_yaml'), + ('group_user', 'get_server_resources'), + ('group_user', 'get_container_exec'), + ('group_admin', 'admin_list_submitted_templates'), + ('group_admin', 'admin_get_template_detail'), + ('group_admin', 'admin_approve_template'), + ('group_admin', 'admin_reject_template'), + ('group_admin', 'admin_list_template_versions'), + ('group_admin', 'admin_list_template_reviews'), + ('group_admin', 'admin_validate_template_security'), + ('group_user', 'list_available_roles'), + ('group_user', 'get_role_details'), + ('group_user', 'get_role_requirements'), + ('group_user', 'validate_role_vars'), + ('group_user', 'deploy_role'), + ('group_user', 'recommend_stack_services'), + ('group_user', 'deploy_app'), + ('group_user', 'remove_app'), + ('group_user', 'configure_proxy_agent'), + ('group_user', 'get_agent_status'), + ('group_user', 'configure_firewall'), + ('group_user', 'list_firewall_rules'), + ('group_user', 'configure_firewall_from_role') +) +DELETE FROM public.casbin_rule cr +USING tool_policy tp +WHERE cr.ptype = 'p' + AND cr.v0 = tp.subject + AND cr.v1 = '/mcp/tools/' || tp.tool + AND cr.v2 = 'CALL' + AND cr.v3 = '' + AND cr.v4 = '' + AND cr.v5 = ''; diff --git a/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql b/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql new file mode 100644 index 00000000..b564c30f --- /dev/null +++ b/migrations/20260717120018_casbin_mcp_remote_secret_tools.up.sql @@ -0,0 +1,99 @@ +-- Add per-tool Casbin ACL for MCP tool execution. +-- The WebSocket endpoint remains protected separately by /mcp GET. + +WITH tool_policy(subject, tool) AS ( + VALUES + ('group_user', 'list_projects'), + ('group_user', 'get_project'), + ('group_user', 'create_project'), + ('group_user', 'create_project_app'), + ('group_user', 'suggest_resources'), + ('group_user', 'list_templates'), + ('group_user', 'validate_domain'), + ('group_user', 'get_deployment_status'), + ('group_user', 'start_deployment'), + ('group_user', 'cancel_deployment'), + ('group_user', 'list_clouds'), + ('group_user', 'get_cloud'), + ('group_user', 'add_cloud'), + ('group_user', 'delete_cloud'), + ('group_user', 'list_cloud_regions'), + ('group_user', 'list_cloud_server_sizes'), + ('group_user', 'list_cloud_images'), + ('group_user', 'delete_project'), + ('group_user', 'clone_project'), + ('group_user', 'get_user_profile'), + ('group_user', 'get_subscription_plan'), + ('group_user', 'list_installations'), + ('group_user', 'get_installation_details'), + ('group_user', 'search_applications'), + ('group_user', 'search_marketplace_templates'), + ('group_user', 'get_notifications'), + ('group_user', 'mark_notification_read'), + ('group_user', 'mark_all_notifications_read'), + ('group_user', 'initiate_deployment'), + ('group_user', 'trigger_redeploy'), + ('group_user', 'add_app_to_deployment'), + ('group_user', 'get_container_logs'), + ('group_user', 'get_container_health'), + ('group_user', 'list_containers'), + ('group_user', 'restart_container'), + ('group_user', 'diagnose_deployment'), + ('group_user', 'escalate_to_support'), + ('group_user', 'get_live_chat_info'), + ('group_user', 'stop_container'), + ('group_user', 'start_container'), + ('group_user', 'get_error_summary'), + ('group_user', 'get_app_env_vars'), + ('group_user', 'set_app_env_var'), + ('group_user', 'delete_app_env_var'), + ('group_user', 'get_app_config'), + ('group_user', 'update_app_ports'), + ('group_user', 'update_app_domain'), + ('group_user', 'preview_install_config'), + ('group_user', 'get_ansible_role_defaults'), + ('group_user', 'render_ansible_template'), + ('group_user', 'validate_stack_config'), + ('group_user', 'discover_stack_services'), + ('group_user', 'get_vault_config'), + ('group_user', 'set_vault_config'), + ('group_user', 'list_vault_configs'), + ('group_user', 'apply_vault_config'), + ('group_user', 'configure_proxy'), + ('group_user', 'delete_proxy'), + ('group_user', 'list_proxies'), + ('group_user', 'list_project_apps'), + ('group_user', 'get_deployment_resources'), + ('group_user', 'list_remote_secret_targets'), + ('group_user', 'list_remote_service_secrets'), + ('group_user', 'get_remote_service_secret'), + ('group_user', 'set_remote_service_secret'), + ('group_user', 'delete_remote_service_secret'), + ('group_user', 'get_docker_compose_yaml'), + ('group_user', 'get_server_resources'), + ('group_user', 'get_container_exec'), + ('group_admin', 'admin_list_submitted_templates'), + ('group_admin', 'admin_get_template_detail'), + ('group_admin', 'admin_approve_template'), + ('group_admin', 'admin_reject_template'), + ('group_admin', 'admin_list_template_versions'), + ('group_admin', 'admin_list_template_reviews'), + ('group_admin', 'admin_validate_template_security'), + ('group_user', 'list_available_roles'), + ('group_user', 'get_role_details'), + ('group_user', 'get_role_requirements'), + ('group_user', 'validate_role_vars'), + ('group_user', 'deploy_role'), + ('group_user', 'recommend_stack_services'), + ('group_user', 'deploy_app'), + ('group_user', 'remove_app'), + ('group_user', 'configure_proxy_agent'), + ('group_user', 'get_agent_status'), + ('group_user', 'configure_firewall'), + ('group_user', 'list_firewall_rules'), + ('group_user', 'configure_firewall_from_role') +) +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +SELECT 'p', subject, '/mcp/tools/' || tool, 'CALL', '', '', '' +FROM tool_policy +ON CONFLICT DO NOTHING; diff --git a/src/connectors/admin_service/jwt.rs b/src/connectors/admin_service/jwt.rs index 7016685c..a0fb796c 100644 --- a/src/connectors/admin_service/jwt.rs +++ b/src/connectors/admin_service/jwt.rs @@ -57,6 +57,7 @@ pub fn user_from_jwt_claims(claims: &JwtClaims) -> models::User { email_confirmed: false, first_name: "Service".to_string(), last_name: "Account".to_string(), + mfa_verified: false, access_token: None, } } diff --git a/src/console/commands/appclient/new.rs b/src/console/commands/appclient/new.rs index 66ea3a16..f454a568 100644 --- a/src/console/commands/appclient/new.rs +++ b/src/console/commands/appclient/new.rs @@ -32,6 +32,7 @@ impl crate::console::commands::CallableTrait for NewCommand { email: "email".to_string(), email_confirmed: true, role: "role".to_string(), + mfa_verified: false, access_token: None, }; crate::routes::client::add_handler_inner(&user.id, settings, db_pool).await?; diff --git a/src/forms/user.rs b/src/forms/user.rs index 4ef5954f..4de809d0 100644 --- a/src/forms/user.rs +++ b/src/forms/user.rs @@ -25,6 +25,10 @@ pub struct User { pub email: String, #[serde(rename = "email_confirmed")] pub email_confirmed: bool, + #[serde(default, alias = "mfaVerified", alias = "mfa_verified")] + pub mfa_verified: Option, + #[serde(default, alias = "twoFactorVerified", alias = "two_factor_verified")] + pub two_factor_verified: Option, pub social: Option, pub website: Option, pub currency: Value, @@ -135,6 +139,8 @@ impl TryInto for UserForm { email: self.user.email, email_confirmed: self.user.email_confirmed, role: self.user.role, + mfa_verified: self.user.mfa_verified.unwrap_or(false) + || self.user.two_factor_verified.unwrap_or(false), access_token: None, }) } diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index fd6fdb3a..3e51ef51 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -1,5 +1,9 @@ use crate::configuration::Settings; use crate::models; +use actix_casbin_auth::{ + casbin::{CoreApi, Error as CasbinError}, + CasbinService, +}; use actix_web::web; use async_trait::async_trait; use serde_json::Value; @@ -33,6 +37,7 @@ use crate::mcp::tools::{ DeleteCloudTool, DeleteProjectTool, DeleteProxyTool, + DeleteRemoteServiceSecretTool, // Ansible Roles tools DeployAppTool, DeployRoleTool, @@ -57,6 +62,7 @@ use crate::mcp::tools::{ GetLiveChatInfoTool, GetNotificationsTool, GetProjectTool, + GetRemoteServiceSecretTool, GetRoleDetailsTool, GetRoleRequirementsTool, GetServerResourcesTool, @@ -76,6 +82,8 @@ use crate::mcp::tools::{ ListProjectAppsTool, ListProjectsTool, ListProxiesTool, + ListRemoteSecretTargetsTool, + ListRemoteServiceSecretsTool, ListTemplatesTool, ListVaultConfigsTool, MarkAllNotificationsReadTool, @@ -89,6 +97,7 @@ use crate::mcp::tools::{ SearchApplicationsTool, SearchMarketplaceTemplatesTool, SetAppEnvVarTool, + SetRemoteServiceSecretTool, SetVaultConfigTool, StartContainerTool, StartDeploymentTool, @@ -111,6 +120,55 @@ pub struct ToolContext { pub settings: web::Data, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolAccessPolicy { + pub object: String, + pub action: &'static str, + pub requires_mfa: bool, +} + +const MCP_TOOL_ACTION: &str = "CALL"; + +const MFA_REQUIRED_TOOLS: &[&str] = &[ + "create_project", + "create_project_app", + "start_deployment", + "cancel_deployment", + "add_cloud", + "delete_cloud", + "delete_project", + "clone_project", + "mark_notification_read", + "mark_all_notifications_read", + "initiate_deployment", + "trigger_redeploy", + "add_app_to_deployment", + "restart_container", + "escalate_to_support", + "stop_container", + "start_container", + "set_app_env_var", + "delete_app_env_var", + "update_app_ports", + "update_app_domain", + "set_vault_config", + "apply_vault_config", + "configure_proxy", + "delete_proxy", + "set_remote_service_secret", + "delete_remote_service_secret", + "get_container_exec", + "admin_approve_template", + "admin_reject_template", + "admin_validate_template_security", + "deploy_role", + "deploy_app", + "remove_app", + "configure_proxy_agent", + "configure_firewall", + "configure_firewall_from_role", +]; + /// Trait for tool handlers #[async_trait] pub trait ToolHandler: Send + Sync { @@ -248,6 +306,28 @@ impl ToolRegistry { Box::new(GetDeploymentResourcesTool), ); + // Vault-backed remote service secrets + registry.register( + "list_remote_secret_targets", + Box::new(ListRemoteSecretTargetsTool), + ); + registry.register( + "list_remote_service_secrets", + Box::new(ListRemoteServiceSecretsTool), + ); + registry.register( + "get_remote_service_secret", + Box::new(GetRemoteServiceSecretTool), + ); + registry.register( + "set_remote_service_secret", + Box::new(SetRemoteServiceSecretTool), + ); + registry.register( + "delete_remote_service_secret", + Box::new(DeleteRemoteServiceSecretTool), + ); + // Phase 7: Advanced Monitoring & Troubleshooting tools registry.register( "get_docker_compose_yaml", @@ -316,8 +396,41 @@ impl ToolRegistry { } /// Get a tool handler by name - pub fn get(&self, name: &str) -> Option<&Box> { - self.handlers.get(name) + pub fn get(&self, name: &str) -> Option<&dyn ToolHandler> { + self.handlers.get(name).map(Box::as_ref) + } + + pub fn access_policy(&self, name: &str) -> Option { + self.has_tool(name).then(|| ToolAccessPolicy { + object: format!("/mcp/tools/{name}"), + action: MCP_TOOL_ACTION, + requires_mfa: MFA_REQUIRED_TOOLS.contains(&name), + }) + } + + pub async fn authorize_call( + &self, + name: &str, + user: &models::User, + casbin_service: CasbinService, + ) -> Result<(), String> { + let Some(policy) = self.access_policy(name) else { + return Err("Forbidden: MCP tool call has no registered ACL policy".to_string()); + }; + + let allowed = enforce_tool_policy(casbin_service, &user.role, &policy) + .await + .map_err(|err| format!("ACL check failed for MCP tool: {err}"))?; + + if !allowed { + return Err("Forbidden: MCP tool call is not allowed by ACL".to_string()); + } + + if policy.requires_mfa && !user.has_verified_mfa() { + return Err("Two-factor authentication is required for this MCP tool".to_string()); + } + + Ok(()) } /// List all available tools @@ -336,8 +449,102 @@ impl ToolRegistry { } } +async fn enforce_tool_policy( + mut casbin_service: CasbinService, + role: &str, + policy: &ToolAccessPolicy, +) -> Result { + let enforcer = casbin_service.get_enforcer(); + let mut lock = enforcer.write().await; + lock.enforce_mut(vec![ + role.to_string(), + policy.object.to_string(), + policy.action.to_string(), + ]) +} + impl Default for ToolRegistry { fn default() -> Self { Self::new() } } + +#[cfg(test)] +mod tests { + use super::ToolRegistry; + + #[test] + fn all_registered_tools_have_acl_policy() { + let registry = ToolRegistry::new(); + + for tool in registry.list_tools() { + let policy = registry + .access_policy(&tool.name) + .unwrap_or_else(|| panic!("{} should require policy", tool.name)); + + assert_eq!(policy.object, format!("/mcp/tools/{}", tool.name)); + assert_eq!(policy.action, "CALL"); + } + } + + #[test] + fn sensitive_write_tools_have_acl_and_mfa_policy() { + let registry = ToolRegistry::new(); + + let set_policy = registry + .access_policy("set_remote_service_secret") + .expect("set tool should require policy"); + assert_eq!(set_policy.object, "/mcp/tools/set_remote_service_secret"); + assert_eq!(set_policy.action, "CALL"); + assert!(set_policy.requires_mfa); + + let delete_policy = registry + .access_policy("delete_remote_service_secret") + .expect("delete tool should require policy"); + assert_eq!( + delete_policy.object, + "/mcp/tools/delete_remote_service_secret" + ); + assert_eq!(delete_policy.action, "CALL"); + assert!(delete_policy.requires_mfa); + + let vault_policy = registry + .access_policy("apply_vault_config") + .expect("vault config apply should require policy"); + assert!(vault_policy.requires_mfa); + + let deploy_policy = registry + .access_policy("deploy_app") + .expect("deploy app should require policy"); + assert!(deploy_policy.requires_mfa); + + let admin_validate_policy = registry + .access_policy("admin_validate_template_security") + .expect("admin security validation should require policy"); + assert!(admin_validate_policy.requires_mfa); + } + + #[test] + fn read_tools_have_acl_without_step_up_policy() { + let registry = ToolRegistry::new(); + + let list_policy = registry + .access_policy("list_remote_service_secrets") + .expect("list tool should require policy"); + assert_eq!(list_policy.object, "/mcp/tools/list_remote_service_secrets"); + assert!(!list_policy.requires_mfa); + + let get_policy = registry + .access_policy("get_remote_service_secret") + .expect("get tool should require policy"); + assert_eq!(get_policy.object, "/mcp/tools/get_remote_service_secret"); + assert!(!get_policy.requires_mfa); + } + + #[test] + fn unknown_tools_have_no_policy() { + let registry = ToolRegistry::new(); + + assert!(registry.access_policy("unknown_tool").is_none()); + } +} diff --git a/src/mcp/tools/agent_control.rs b/src/mcp/tools/agent_control.rs index ab9dfbc3..bb342bf4 100644 --- a/src/mcp/tools/agent_control.rs +++ b/src/mcp/tools/agent_control.rs @@ -397,7 +397,7 @@ impl ToolHandler for ConfigureProxyAgentTool { }, "ssl_enabled": { "type": "boolean", - "description": "Enable SSL with Let's Encrypt (default: true)" + "description": "Enable SSL with Let's Encrypt; set false for plain HTTP hosts (default: true)" }, "action": { "type": "string", diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 92ad924d..25673062 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -11,6 +11,7 @@ pub mod monitoring; pub mod project; pub mod proxy; pub mod recommendations; +pub mod remote_secrets; pub mod support; pub mod templates; pub mod user_service; @@ -28,6 +29,7 @@ pub use monitoring::*; pub use project::*; pub use proxy::*; pub use recommendations::*; +pub use remote_secrets::*; pub use support::*; pub use templates::*; pub use user_service::*; diff --git a/src/mcp/tools/proxy.rs b/src/mcp/tools/proxy.rs index 771c8d65..75fce84c 100644 --- a/src/mcp/tools/proxy.rs +++ b/src/mcp/tools/proxy.rs @@ -155,7 +155,7 @@ impl ToolHandler for ConfigureProxyTool { fn schema(&self) -> Tool { Tool { name: "configure_proxy".to_string(), - description: "Configure a reverse proxy (Nginx Proxy Manager) to route a domain to an application. Creates SSL certificates automatically with Let's Encrypt.".to_string(), + description: "Configure a reverse proxy (Nginx Proxy Manager) to route a domain to an application. Set ssl_enabled=false for plain HTTP hosts; when enabled, SSL certificates are requested with Let's Encrypt.".to_string(), input_schema: json!({ "type": "object", "properties": { @@ -186,11 +186,11 @@ impl ToolHandler for ConfigureProxyTool { }, "ssl_enabled": { "type": "boolean", - "description": "Enable SSL with Let's Encrypt (default: true)" + "description": "Enable SSL with Let's Encrypt; set false for plain HTTP hosts (default: true)" }, "ssl_forced": { "type": "boolean", - "description": "Force HTTPS redirect (default: true)" + "description": "Force HTTPS redirect when SSL is enabled (default: true)" }, "http2_support": { "type": "boolean", diff --git a/src/mcp/tools/remote_secrets.rs b/src/mcp/tools/remote_secrets.rs new file mode 100644 index 00000000..f58c1b1c --- /dev/null +++ b/src/mcp/tools/remote_secrets.rs @@ -0,0 +1,451 @@ +//! MCP tools for Vault-backed remote service secrets. + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::db; +use crate::forms::RemoteSecretMetadataResponse; +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; +use crate::services::VaultService; + +async fn ensure_owned_project(context: &ToolContext, project_id: i32) -> Result<(), String> { + let project = db::project::fetch(&context.pg_pool, project_id) + .await + .map_err(|e| format!("Failed to fetch project: {}", e))? + .ok_or_else(|| "Project not found".to_string())?; + + if project.user_id != context.user.id { + return Err("Project not found".to_string()); + } + + Ok(()) +} + +async fn ensure_owned_target( + context: &ToolContext, + project_id: i32, + target_code: &str, +) -> Result<(), String> { + ensure_owned_project(context, project_id).await?; + + db::project_app::fetch_by_project_and_code(&context.pg_pool, project_id, target_code) + .await + .map_err(|e| format!("Failed to fetch target: {}", e))? + .ok_or_else(|| format!("Deployable service/app target '{}' not found", target_code))?; + + Ok(()) +} + +fn validate_secret_name(name: &str) -> Result<(), String> { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first == '_' || first.is_ascii_alphabetic() => {} + _ => { + return Err(format!( + "Invalid secret name '{}': must match [A-Za-z_][A-Za-z0-9_]*", + name + )); + } + } + + if chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) { + Ok(()) + } else { + Err(format!( + "Invalid secret name '{}': must match [A-Za-z_][A-Za-z0-9_]*", + name + )) + } +} + +fn vault_from_context(context: &ToolContext) -> Result { + VaultService::from_settings(&context.settings.vault) + .map_err(|error| format!("Vault is not available for remote secrets: {}", error)) +} + +fn render_json(value: Value) -> ToolContent { + ToolContent::Text { + text: serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()), + } +} + +/// List deployable service/app targets that can receive remote service secrets. +pub struct ListRemoteSecretTargetsTool; + +#[async_trait] +impl ToolHandler for ListRemoteSecretTargetsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + ensure_owned_project(context, params.project_id).await?; + + let targets = db::project_app::fetch_by_project(&context.pg_pool, params.project_id) + .await + .map_err(|e| format!("Failed to list remote secret targets: {}", e))?; + + let items: Vec = targets + .into_iter() + .map(|target| { + json!({ + "code": target.code, + "name": target.name, + "enabled": target.enabled, + "image": target.image + }) + }) + .collect(); + + Ok(render_json(json!({ + "project_id": params.project_id, + "targets": items, + "count": items.len(), + "note": "Use one of these target codes with service-scope remote secrets." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_remote_secret_targets".to_string(), + description: "List deployable service/app target codes that can receive Vault-backed service-scope remote secrets for a project.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID to inspect" + } + }, + "required": ["project_id"] + }), + } + } +} + +/// List metadata for remote service secrets on one target. +pub struct ListRemoteServiceSecretsTool; + +#[async_trait] +impl ToolHandler for ListRemoteServiceSecretsTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let items: Vec = db::remote_secret::list_service_secrets( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ) + .await + .map_err(|e| format!("Failed to list remote service secrets: {}", e))? + .into_iter() + .map(Into::into) + .collect(); + + Ok(render_json(json!({ + "project_id": params.project_id, + "target_code": params.target_code, + "secrets": items, + "count": items.len(), + "note": "Secret values are not returned; only metadata is exposed." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "list_remote_service_secrets".to_string(), + description: "List metadata for Vault-backed service-scope remote secrets on one deployable service/app target. Plaintext values are never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + } + }, + "required": ["project_id", "target_code"] + }), + } + } +} + +/// Get metadata for one remote service secret. +pub struct GetRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for GetRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let secret = db::remote_secret::fetch_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ) + .await + .map_err(|e| format!("Failed to fetch remote service secret: {}", e))? + .ok_or_else(|| "Secret not found".to_string())?; + + Ok(render_json(json!({ + "secret": RemoteSecretMetadataResponse::from(secret), + "note": "Secret values are not returned; only metadata is exposed." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "get_remote_service_secret".to_string(), + description: "Get metadata for one Vault-backed service-scope remote secret. Plaintext values are never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + } + }, + "required": ["project_id", "target_code", "name"] + }), + } + } +} + +/// Set or replace one remote service secret. +pub struct SetRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for SetRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + value: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + if params.value.is_empty() { + return Err("Secret value must not be empty".to_string()); + } + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let vault = vault_from_context(context)?; + let vault_path = vault.service_secret_path( + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ); + + vault + .store_secret_value(&vault_path, ¶ms.value) + .await + .map_err(|e| format!("Failed to store secret value in Vault: {}", e))?; + + let secret = db::remote_secret::upsert_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + &vault_path, + &context.user.id, + "synced", + ) + .await + .map_err(|e| format!("Failed to upsert remote service secret metadata: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + target_code = %params.target_code, + secret_name = %params.name, + "Set remote service secret metadata via MCP" + ); + + Ok(render_json(json!({ + "secret": RemoteSecretMetadataResponse::from(secret), + "note": "Secret stored in Vault. Plaintext value is not returned." + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "set_remote_service_secret".to_string(), + description: "Set or replace a Vault-backed service-scope remote secret for one deployable service/app target. The value is written to Vault and is never returned.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + }, + "value": { + "type": "string", + "description": "Secret value to store in Vault" + } + }, + "required": ["project_id", "target_code", "name", "value"] + }), + } + } +} + +/// Delete one remote service secret. +pub struct DeleteRemoteServiceSecretTool; + +#[async_trait] +impl ToolHandler for DeleteRemoteServiceSecretTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + #[derive(Deserialize)] + struct Args { + project_id: i32, + target_code: String, + name: String, + } + + let params: Args = + serde_json::from_value(args).map_err(|e| format!("Invalid arguments: {}", e))?; + validate_secret_name(¶ms.name)?; + ensure_owned_target(context, params.project_id, ¶ms.target_code).await?; + + let secret = db::remote_secret::fetch_service_secret( + &context.pg_pool, + &context.user.id, + params.project_id, + ¶ms.target_code, + ¶ms.name, + ) + .await + .map_err(|e| format!("Failed to fetch remote service secret: {}", e))? + .ok_or_else(|| "Secret not found".to_string())?; + + let vault = vault_from_context(context)?; + vault + .delete_secret_value(&secret.vault_path) + .await + .map_err(|e| format!("Failed to delete secret value from Vault: {}", e))?; + + db::remote_secret::delete_secret_by_id(&context.pg_pool, secret.id) + .await + .map_err(|e| format!("Failed to delete remote service secret metadata: {}", e))?; + + tracing::info!( + user_id = %context.user.id, + project_id = params.project_id, + target_code = %params.target_code, + secret_name = %params.name, + "Deleted remote service secret metadata via MCP" + ); + + Ok(render_json(json!({ + "deleted": true, + "project_id": params.project_id, + "target_code": params.target_code, + "name": params.name, + "scope": "service" + }))) + } + + fn schema(&self) -> Tool { + Tool { + name: "delete_remote_service_secret".to_string(), + description: "Delete a Vault-backed service-scope remote secret from one deployable service/app target.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "project_id": { + "type": "number", + "description": "Project ID containing the target" + }, + "target_code": { + "type": "string", + "description": "Deployable service/app target code from list_remote_secret_targets" + }, + "name": { + "type": "string", + "description": "Secret name, matching [A-Za-z_][A-Za-z0-9_]*" + } + }, + "required": ["project_id", "target_code", "name"] + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::{validate_secret_name, ListRemoteSecretTargetsTool, SetRemoteServiceSecretTool}; + use crate::mcp::registry::ToolHandler; + + #[test] + fn validates_cli_compatible_secret_names() { + assert!(validate_secret_name("S3_BUCKET").is_ok()); + assert!(validate_secret_name("_TOKEN").is_ok()); + assert!(validate_secret_name("1TOKEN").is_err()); + assert!(validate_secret_name("S3-BUCKET").is_err()); + } + + #[test] + fn remote_secret_schemas_use_target_language() { + let list_schema = ListRemoteSecretTargetsTool.schema(); + assert_eq!(list_schema.name, "list_remote_secret_targets"); + assert!(list_schema.description.contains("service/app target")); + + let set_schema = SetRemoteServiceSecretTool.schema(); + assert_eq!(set_schema.name, "set_remote_service_secret"); + assert!(set_schema.description.contains("Vault-backed")); + assert!(set_schema.description.contains("never returned")); + } +} diff --git a/src/mcp/websocket.rs b/src/mcp/websocket.rs index 159d84ca..44c3daa8 100644 --- a/src/mcp/websocket.rs +++ b/src/mcp/websocket.rs @@ -1,6 +1,7 @@ use crate::configuration::Settings; use crate::models; use actix::{Actor, ActorContext, AsyncContext, StreamHandler}; +use actix_casbin_auth::CasbinService; use actix_web::{web, Error, HttpRequest, HttpResponse}; use actix_web_actors::ws; use sqlx::PgPool; @@ -27,6 +28,7 @@ pub struct McpWebSocket { registry: Arc, pg_pool: PgPool, settings: web::Data, + casbin_service: CasbinService, hb: Instant, } @@ -36,6 +38,7 @@ impl McpWebSocket { registry: Arc, pg_pool: PgPool, settings: web::Data, + casbin_service: CasbinService, ) -> Self { Self { user, @@ -43,6 +46,7 @@ impl McpWebSocket { registry, pg_pool, settings, + casbin_service, hb: Instant::now(), } } @@ -169,6 +173,19 @@ impl McpWebSocket { match self.registry.get(&call_req.name) { Some(handler) => { + if let Err(err) = self + .registry + .authorize_call(&call_req.name, &self.user, self.casbin_service.clone()) + .await + { + tracing::warn!(tool = %call_req.name, error = %err, "MCP tool authorization failed"); + let response = CallToolResponse::error(format!("Error: {}", err)); + return JsonRpcResponse::success( + req.id, + serde_json::to_value(response).unwrap(), + ); + } + let context = ToolContext { user: self.user.clone(), pg_pool: self.pg_pool.clone(), @@ -264,6 +281,7 @@ impl StreamHandler> for McpWebSocket { let registry = self.registry.clone(); let pg_pool = self.pg_pool.clone(); let settings = self.settings.clone(); + let casbin_service = self.casbin_service.clone(); let fut = async move { let ws = McpWebSocket { @@ -272,6 +290,7 @@ impl StreamHandler> for McpWebSocket { registry, pg_pool, settings, + casbin_service, hb: Instant::now(), }; ws.handle_jsonrpc(request).await @@ -323,7 +342,7 @@ impl actix::Handler for McpWebSocket { /// WebSocket route handler - entry point for MCP connections #[tracing::instrument( name = "MCP WebSocket connection", - skip(req, stream, user, registry, pg_pool, settings) + skip(req, stream, user, registry, pg_pool, settings, casbin_service) )] pub async fn mcp_websocket( req: HttpRequest, @@ -332,6 +351,7 @@ pub async fn mcp_websocket( registry: web::Data>, pg_pool: web::Data, settings: web::Data, + casbin_service: web::Data, ) -> Result { tracing::info!( "New MCP WebSocket connection request from user: {}", @@ -343,6 +363,7 @@ pub async fn mcp_websocket( registry.get_ref().clone(), pg_pool.get_ref().clone(), settings.clone(), + casbin_service.get_ref().clone(), ); // The MCP SDK requests subprotocol "mcp" via Sec-WebSocket-Protocol header. diff --git a/src/middleware/authentication/method/f_agent.rs b/src/middleware/authentication/method/f_agent.rs index 8d8f6de2..e2c12c00 100644 --- a/src/middleware/authentication/method/f_agent.rs +++ b/src/middleware/authentication/method/f_agent.rs @@ -159,6 +159,7 @@ pub async fn try_agent(req: &mut ServiceRequest) -> Result { last_name: format!("#{}", &agent.id.to_string()[..8]), // First 8 chars of UUID email: format!("agent+{}@system.local", agent.deployment_hash), email_confirmed: true, + mfa_verified: false, access_token: None, }; diff --git a/src/middleware/authentication/method/f_cookie.rs b/src/middleware/authentication/method/f_cookie.rs index 164c74cb..3f84fcc8 100644 --- a/src/middleware/authentication/method/f_cookie.rs +++ b/src/middleware/authentication/method/f_cookie.rs @@ -50,8 +50,8 @@ pub async fn try_cookie(req: &mut ServiceRequest) -> Result { } }; - // Attach the access token to the user for proxy requests to other services - user.access_token = Some(token); + // Attach the access token to the user for proxy requests and MFA-sensitive checks. + user = user.with_token(token); // Control access using user role tracing::debug!("ACL check for role (cookie auth): {}", user.role.clone()); diff --git a/src/middleware/authentication/method/f_oauth.rs b/src/middleware/authentication/method/f_oauth.rs index e3aeffcc..d13249b1 100644 --- a/src/middleware/authentication/method/f_oauth.rs +++ b/src/middleware/authentication/method/f_oauth.rs @@ -133,8 +133,8 @@ pub async fn try_oauth(req: &mut ServiceRequest) -> Result { .await .map_err(|err| format!("{err}"))?; - // Attach the access token to the user for proxy requests to other services - user.access_token = Some(token); + // Attach the access token to the user for proxy requests and MFA-sensitive checks. + user = user.with_token(token); // control access using user role tracing::debug!("ACL check for role: {}", user.role.clone()); @@ -179,6 +179,7 @@ pub async fn fetch_user( email: "test@example.com".to_string(), role: "group_user".to_string(), email_confirmed: true, + mfa_verified: false, access_token: None, }; return Ok(user); diff --git a/src/middleware/authentication/method/f_query.rs b/src/middleware/authentication/method/f_query.rs index 374c934c..050f2dbe 100644 --- a/src/middleware/authentication/method/f_query.rs +++ b/src/middleware/authentication/method/f_query.rs @@ -57,7 +57,7 @@ pub async fn try_query(req: &mut ServiceRequest) -> Result { } }; - user.access_token = Some(token); + user = user.with_token(token); tracing::debug!("ACL check for role (query auth): {}", user.role.clone()); let acl_vals = actix_casbin_auth::CasbinVals { diff --git a/src/models/user.rs b/src/models/user.rs index c5510112..7de9535c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,4 +1,6 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::Deserialize; +use serde_json::Value; #[derive(Deserialize, Clone)] pub struct User { @@ -8,6 +10,8 @@ pub struct User { pub email: String, pub role: String, pub email_confirmed: bool, + #[serde(default)] + pub mfa_verified: bool, /// Access token used for proxy requests to other services (e.g., User Service) /// This is set during authentication and used for MCP tool calls. #[serde(skip)] @@ -17,9 +21,21 @@ pub struct User { impl User { /// Create a new User with an access token for service proxy requests pub fn with_token(mut self, token: String) -> Self { + if access_token_has_mfa_claim(&token) { + self.mfa_verified = true; + } self.access_token = Some(token); self } + + pub fn has_verified_mfa(&self) -> bool { + self.mfa_verified + || self + .access_token + .as_deref() + .map(access_token_has_mfa_claim) + .unwrap_or(false) + } } impl std::fmt::Debug for User { @@ -31,7 +47,106 @@ impl std::fmt::Debug for User { .field("email", &self.email) .field("role", &self.role) .field("email_confirmed", &self.email_confirmed) + .field("mfa_verified", &self.mfa_verified) .field("access_token", &"[REDACTED]") .finish() } } + +pub fn access_token_has_mfa_claim(token: &str) -> bool { + let Some(payload) = token.split('.').nth(1) else { + return false; + }; + let Ok(decoded) = URL_SAFE_NO_PAD.decode(payload) else { + return false; + }; + let Ok(claims) = serde_json::from_slice::(&decoded) else { + return false; + }; + + claim_bool( + &claims, + &[ + "mfa", + "mfa_verified", + "mfaVerified", + "two_factor_verified", + "twoFactorVerified", + ], + ) || claim_contains_mfa(&claims, "amr") + || claim_contains_mfa(&claims, "acr") +} + +fn claim_bool(claims: &Value, names: &[&str]) -> bool { + names + .iter() + .any(|name| claims.get(*name).and_then(Value::as_bool).unwrap_or(false)) +} + +fn claim_contains_mfa(claims: &Value, name: &str) -> bool { + let Some(value) = claims.get(name) else { + return false; + }; + + match value { + Value::String(value) => is_mfa_method(value), + Value::Array(values) => values.iter().filter_map(Value::as_str).any(is_mfa_method), + _ => false, + } +} + +fn is_mfa_method(value: &str) -> bool { + matches!( + value.to_ascii_lowercase().as_str(), + "mfa" | "2fa" | "otp" | "totp" | "webauthn" | "fido" | "fido2" | "u2f" + ) || value.to_ascii_lowercase().contains("multi-factor") +} + +#[cfg(test)] +mod tests { + use super::{access_token_has_mfa_claim, User}; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::json; + + fn token_with_claims(claims: serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(json!({"alg": "none"}).to_string()); + let payload = URL_SAFE_NO_PAD.encode(claims.to_string()); + format!("{header}.{payload}.signature") + } + + #[test] + fn detects_mfa_from_amr_claim() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd", "totp"]})); + assert!(access_token_has_mfa_claim(&token)); + } + + #[test] + fn detects_mfa_from_boolean_claim() { + let token = token_with_claims(json!({"sub": "user-1", "mfa_verified": true})); + assert!(access_token_has_mfa_claim(&token)); + } + + #[test] + fn rejects_token_without_mfa_claim() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd"]})); + assert!(!access_token_has_mfa_claim(&token)); + } + + #[test] + fn with_token_marks_user_mfa_verified_when_claim_is_present() { + let token = token_with_claims(json!({"sub": "user-1", "amr": ["pwd", "webauthn"]})); + let user = User { + id: "user-1".to_string(), + first_name: "Test".to_string(), + last_name: "User".to_string(), + email: "user@example.com".to_string(), + role: "group_user".to_string(), + email_confirmed: true, + mfa_verified: false, + access_token: None, + } + .with_token(token); + + assert!(user.has_verified_mfa()); + } +} diff --git a/src/routes/handoff/mod.rs b/src/routes/handoff/mod.rs index 2bbeb63c..2ec4f877 100644 --- a/src/routes/handoff/mod.rs +++ b/src/routes/handoff/mod.rs @@ -464,6 +464,7 @@ mod tests { email: "user@example.com".to_string(), role: "group_user".to_string(), email_confirmed: true, + mfa_verified: false, access_token: Some(token.to_string()), } } diff --git a/src/startup.rs b/src/startup.rs index 2ec3bda6..c1f4bce1 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -79,8 +79,7 @@ pub async fn run( let vault_client = helpers::VaultClient::new(&settings.vault); let vault_client = web::Data::new(vault_client); - let oauth_http_client = build_oauth_http_client(&settings) - .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; + let oauth_http_client = build_oauth_http_client(&settings).map_err(std::io::Error::other)?; let oauth_http_client = web::Data::new(oauth_http_client); let oauth_cache = web::Data::new(middleware::authentication::OAuthCache::new( @@ -432,6 +431,7 @@ pub async fn run( .app_data(mq_manager.clone()) .app_data(vault_client.clone()) .app_data(mcp_registry.clone()) + .app_data(web::Data::new(authorization.clone())) .app_data(user_service_connector.clone()) .app_data(install_service_connector.clone()) .app_data(dockerhub_connector.clone()) diff --git a/website/dist/index.html b/website/dist/index.html index 3545e66e..d64a7700 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -3,8 +3,8 @@ - Stacker v0.2.7 — AI Infrastructure CLI by TryDirect - + Stacker v0.2.8 — AI Infrastructure CLI by TryDirect + @@ -52,7 +52,7 @@
- New Release v0.2.7 · AI-Powered · Cloud + Bare Metal + New Release v0.2.8 · AI-Powered · Cloud + Bare Metal

Ship your stack from code to server
@@ -74,17 +74,16 @@

-
v0.2.7
+
v0.2.8

- Latest release includes pipes, agent control + firewall, - Kata runtime, marketplace submit/status/logs, and - multi-user security hardening. + Latest release includes remote service secrets, cloud firewalls, + IP persistence, MCP secret tools, and hardened agent operations.

- Pipe automation - Agent ops - Kata isolation - Marketplace flows + Service secrets + Cloud firewall + IP retention + MCP tools
@@ -200,29 +199,29 @@

SSH Key Management

-

Stacker v0.2.7
brings the operator tools.

-

The newest release expands Stacker from deployment setup into ongoing operations, app-to-app automation, secure runtime options, and marketplace delivery.

+

Stacker v0.2.8
brings safer operator workflows.

+

The newest release improves remote service secrets, cloud firewall operations, paused install recovery, and MCP coverage for AI-driven infrastructure work.

- Automation -

Pipes

-

Create, activate, trigger, replay, and inspect pipe executions to move data between apps without building glue code first.

+ Secrets +

Remote service targets

+

Store Vault-backed secrets for deployable service/app targets from stacker.yml and supported Compose services, with metadata-only reads.

Operations -

Remote agent control

-

Health checks, logs, deploy-app, configure-proxy, and configure-firewall are now first-class CLI workflows for live environments.

+

Cloud firewall controls

+

Open, remove, and list provider firewall rules without SSH, while guest OS firewall operations remain available through the agent.

Security -

Kata runtime + hardening

-

Choose kata for stronger container isolation and ship with tightened cross-user access controls backed by new IDOR coverage.

+

Paused install recovery

+

Cloud/server deployments now keep discovered IP addresses even when installers pause or fail, improving SSH repair and retry workflows.

- Distribution -

Marketplace workflows

-

Submit stacks for review, track approval status, and inspect reviewer feedback directly from the CLI.

+ MCP +

Remote secret tools

+

AI clients can list remote secret targets and manage service secret metadata while plaintext values stay in Vault.

@@ -362,7 +361,7 @@

Ask how to connect a metal bare server

Make sure that your SSH key is correctly set up and that the user has the necessary permissions on the server.
-
New in v0.2.7
+
New in v0.2.8

Open ports and keep your database private

❯ stacker agent configure-firewall \
     --public-ports 80/tcp,443/tcp \
@@ -562,7 +561,7 @@ 

21 top-level commands.
stacker secrets

-

Manage .env values, mask secrets in output, and validate config references before deploys.

+

Manage .env values plus remote Vault-backed service/app target secrets with metadata-only reads.

@@ -640,7 +639,7 @@

Deploy anywhere.
Your

Ready to deploy
smarter?

-

Ship with the current Stacker release: AI config, bare-metal deploys, pipes, agent ops, and marketplace workflows in one place.

+

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

diff --git a/website/src/index.html b/website/src/index.html index 3545e66e..d64a7700 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -3,8 +3,8 @@ - Stacker v0.2.7 — AI Infrastructure CLI by TryDirect - + Stacker v0.2.8 — AI Infrastructure CLI by TryDirect + @@ -52,7 +52,7 @@
-
v0.2.7
+
v0.2.8

- Latest release includes pipes, agent control + firewall, - Kata runtime, marketplace submit/status/logs, and - multi-user security hardening. + Latest release includes remote service secrets, cloud firewalls, + IP persistence, MCP secret tools, and hardened agent operations.

- Pipe automation - Agent ops - Kata isolation - Marketplace flows + Service secrets + Cloud firewall + IP retention + MCP tools
@@ -200,29 +199,29 @@

SSH Key Management

-

Stacker v0.2.7
brings the operator tools.

-

The newest release expands Stacker from deployment setup into ongoing operations, app-to-app automation, secure runtime options, and marketplace delivery.

+

Stacker v0.2.8
brings safer operator workflows.

+

The newest release improves remote service secrets, cloud firewall operations, paused install recovery, and MCP coverage for AI-driven infrastructure work.

- Automation -

Pipes

-

Create, activate, trigger, replay, and inspect pipe executions to move data between apps without building glue code first.

+ Secrets +

Remote service targets

+

Store Vault-backed secrets for deployable service/app targets from stacker.yml and supported Compose services, with metadata-only reads.

Operations -

Remote agent control

-

Health checks, logs, deploy-app, configure-proxy, and configure-firewall are now first-class CLI workflows for live environments.

+

Cloud firewall controls

+

Open, remove, and list provider firewall rules without SSH, while guest OS firewall operations remain available through the agent.

Security -

Kata runtime + hardening

-

Choose kata for stronger container isolation and ship with tightened cross-user access controls backed by new IDOR coverage.

+

Paused install recovery

+

Cloud/server deployments now keep discovered IP addresses even when installers pause or fail, improving SSH repair and retry workflows.

- Distribution -

Marketplace workflows

-

Submit stacks for review, track approval status, and inspect reviewer feedback directly from the CLI.

+ MCP +

Remote secret tools

+

AI clients can list remote secret targets and manage service secret metadata while plaintext values stay in Vault.

@@ -362,7 +361,7 @@

Ask how to connect a metal bare server

Make sure that your SSH key is correctly set up and that the user has the necessary permissions on the server.
-
New in v0.2.7
+
New in v0.2.8

Open ports and keep your database private

❯ stacker agent configure-firewall \
     --public-ports 80/tcp,443/tcp \
@@ -562,7 +561,7 @@ 

21 top-level commands.
stacker secrets

-

Manage .env values, mask secrets in output, and validate config references before deploys.

+

Manage .env values plus remote Vault-backed service/app target secrets with metadata-only reads.

@@ -640,7 +639,7 @@

Deploy anywhere.
Your

Ready to deploy
smarter?

-

Ship with the current Stacker release: AI config, bare-metal deploys, pipes, agent ops, and marketplace workflows in one place.

+

Ship with the current Stacker release: AI config, bare-metal deploys, service secrets, cloud firewalls, pipes, and agent ops in one place.

From 0867429052dfa1454a6f3b1d84ad662180f7d573 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 12 May 2026 13:37:37 +0300 Subject: [PATCH 2/3] improvements for service secret registration --- src/bin/stacker.rs | 272 ++++++++++++++++++++----- src/cli/stacker_client.rs | 63 ++++++ src/console/commands/cli/secrets.rs | 299 +++++++++++++++++++++++++++- 3 files changed, 577 insertions(+), 57 deletions(-) diff --git a/src/bin/stacker.rs b/src/bin/stacker.rs index 953dcc23..d1fe6014 100644 --- a/src/bin/stacker.rs +++ b/src/bin/stacker.rs @@ -610,7 +610,7 @@ enum SecretsCommands { stacker secrets set DB_PASSWORD=supersecret\n\ \n\ Remote deployable service/app target secret (project.identity from stacker.yml):\n\ - stacker secrets set S3_SECRET_KEY --scope service --service uploader --body supersecret\n\ + stacker secrets set S3_SECRET_KEY --service uploader --body supersecret\n\ \n\ Use the target code listed by `stacker secrets apps` for --service.\n\ \n\ @@ -630,29 +630,19 @@ enum SecretsCommands { #[arg(long, value_enum)] scope: Option, /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) - #[arg(long, value_name = "PROJECT", requires = "scope")] + #[arg(long, value_name = "PROJECT")] project: Option, /// Deployable service/app target code listed by `stacker secrets apps` - #[arg(long, value_name = "TARGET_CODE", requires = "scope")] + #[arg(long, value_name = "TARGET_CODE")] service: Option, /// Server ID for server-scoped secrets - #[arg(long, value_name = "SERVER_ID", requires = "scope")] + #[arg(long, value_name = "SERVER_ID")] server_id: Option, /// Inline secret value for remote mode - #[arg( - long, - value_name = "VALUE", - requires = "scope", - conflicts_with = "body_file" - )] + #[arg(long, value_name = "VALUE", conflicts_with = "body_file")] body: Option, /// Read the secret value from a file in remote mode - #[arg( - long = "body-file", - value_name = "FILE", - requires = "scope", - conflicts_with = "body" - )] + #[arg(long = "body-file", value_name = "FILE", conflicts_with = "body")] body_file: Option, }, /// Get a local .env secret or remote secret metadata @@ -663,8 +653,8 @@ enum SecretsCommands { Local plaintext value:\n\ stacker secrets get DB_PASSWORD --show\n\ \n\ - Remote metadata only:\n\ - stacker secrets get S3_SECRET_KEY --scope service --service uploader --json\n\ + Remote metadata only:\n\ + stacker secrets get S3_SECRET_KEY --service uploader --json\n\ \n\ Remote get is metadata-only in v1 and does not reveal plaintext values.")] Get { @@ -684,16 +674,16 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] #[arg(long, value_enum)] scope: Option, /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) - #[arg(long, value_name = "PROJECT", requires = "scope")] + #[arg(long, value_name = "PROJECT")] project: Option, /// Deployable service/app target code listed by `stacker secrets apps` - #[arg(long, value_name = "TARGET_CODE", requires = "scope")] + #[arg(long, value_name = "TARGET_CODE")] service: Option, /// Server ID for server-scoped secrets - #[arg(long, value_name = "SERVER_ID", requires = "scope")] + #[arg(long, value_name = "SERVER_ID")] server_id: Option, /// Output metadata as JSON in remote mode - #[arg(long, requires = "scope")] + #[arg(long)] json: bool, }, /// List local .env secrets or remote secret metadata @@ -702,7 +692,7 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] stacker secrets list\n\ \n\ Remote service secrets:\n\ - stacker secrets list --scope service --service uploader\n\ + stacker secrets list --service uploader\n\ \n\ Remote server secrets as JSON:\n\ stacker secrets list --scope server --server-id 42 --json\n\ @@ -723,32 +713,40 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] #[arg(long, value_enum)] scope: Option, /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) - #[arg(long, value_name = "PROJECT", requires = "scope")] + #[arg(long, value_name = "PROJECT")] project: Option, /// Deployable service/app target code listed by `stacker secrets apps` - #[arg(long, value_name = "TARGET_CODE", requires = "scope")] + #[arg(long, value_name = "TARGET_CODE")] service: Option, /// Server ID for server-scoped secrets - #[arg(long, value_name = "SERVER_ID", requires = "scope")] + #[arg(long, value_name = "SERVER_ID")] server_id: Option, /// Output metadata as JSON in remote mode - #[arg(long, requires = "scope")] + #[arg(long)] json: bool, }, /// List valid remote deployable service/app target codes (`stacker secrets apps`) #[command( visible_alias = "services", after_help = "Examples:\n\ - List remote target codes using project.identity from stacker.yml:\n\ - stacker secrets apps\n\ - \n\ + List remote target codes using project.identity from stacker.yml:\n\ + stacker secrets apps\n\ + \n\ + Register one local stacker.yml service as a remote target:\n\ + stacker secrets apps register upload\n\ + \n\ + Sync all local stacker.yml services as remote targets:\n\ + stacker secrets apps sync\n\ + \n\ List remote target codes for a project:\n\ - stacker secrets apps --project blog\n\ + stacker secrets apps --project blog\n\ \n\ Output app metadata as JSON:\n\ stacker secrets apps --json" )] Apps { + #[command(subcommand)] + command: Option, /// Project name or ID (defaults to project.identity in stacker.yml) #[arg(long, value_name = "PROJECT")] project: Option, @@ -761,8 +759,8 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] Local delete:\n\ stacker secrets delete DB_PASSWORD\n\ \n\ - Remote service secret delete:\n\ - stacker secrets delete S3_SECRET_KEY --scope service --service uploader\n\ + Remote service secret delete:\n\ + stacker secrets delete S3_SECRET_KEY --service uploader\n\ \n\ Remote server secret delete:\n\ stacker secrets delete NPM_TOKEN --scope server --server-id 42")] @@ -780,13 +778,13 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] #[arg(long, value_enum)] scope: Option, /// Project name or ID for service-scoped secrets (defaults to project.identity in stacker.yml) - #[arg(long, value_name = "PROJECT", requires = "scope")] + #[arg(long, value_name = "PROJECT")] project: Option, /// Deployable service/app target code listed by `stacker secrets apps` - #[arg(long, value_name = "TARGET_CODE", requires = "scope")] + #[arg(long, value_name = "TARGET_CODE")] service: Option, /// Server ID for server-scoped secrets - #[arg(long, value_name = "SERVER_ID", requires = "scope")] + #[arg(long, value_name = "SERVER_ID")] server_id: Option, }, /// Validate all ${VAR} references in stacker.yml are set in .env or environment @@ -797,6 +795,30 @@ Remote get is metadata-only in v1 and does not reveal plaintext values.")] }, } +#[derive(Debug, Subcommand)] +enum SecretsAppsCommands { + /// Register one local stacker.yml service as a remote secret target + Register { + /// Local service name from stacker.yml + service: String, + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Output registered app metadata as JSON + #[arg(long)] + json: bool, + }, + /// Register/update all local stacker.yml services as remote secret targets + Sync { + /// Project name or ID (defaults to project.identity in stacker.yml) + #[arg(long, value_name = "PROJECT")] + project: Option, + /// Output registered app metadata as JSON + #[arg(long)] + json: bool, + }, +} + #[derive(Debug, Subcommand)] enum CiCommands { /// Export a CI/CD pipeline configuration file @@ -1237,6 +1259,48 @@ enum ProxyCommands { }, } +fn inferred_remote_secret_scope( + scope: Option, + service: &Option, + server_id: Option, +) -> Option { + scope.or_else(|| { + if service.is_some() { + Some(RemoteSecretScope::Service) + } else if server_id.is_some() { + Some(RemoteSecretScope::Server) + } else { + None + } + }) +} + +fn should_use_remote_secret_set( + scope: Option, + project: &Option, + service: &Option, + server_id: Option, + body: &Option, + body_file: &Option, +) -> bool { + scope.is_some() + || project.is_some() + || service.is_some() + || server_id.is_some() + || body.is_some() + || body_file.is_some() +} + +fn should_use_remote_secret_metadata( + scope: Option, + project: &Option, + service: &Option, + server_id: Option, + json: bool, +) -> bool { + scope.is_some() || project.is_some() || service.is_some() || server_id.is_some() || json +} + fn main() -> Result<(), Box> { let cli = match Cli::try_parse() { Ok(cli) => cli, @@ -1539,7 +1603,11 @@ fn get_command( body, body_file, } => { - if let Some(scope) = scope { + if should_use_remote_secret_set( + scope, &project, &service, server_id, &body, &body_file, + ) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); Box::new( stacker::console::commands::cli::secrets::SecretsSetCommand::new_remote( input, scope, project, service, server_id, body, body_file, @@ -1563,7 +1631,9 @@ fn get_command( server_id, json, } => { - if let Some(scope) = scope { + if should_use_remote_secret_metadata(scope, &project, &service, server_id, json) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); Box::new( stacker::console::commands::cli::secrets::SecretsGetCommand::new_remote( key, scope, project, service, server_id, json, @@ -1586,7 +1656,9 @@ fn get_command( server_id, json, } => { - if let Some(scope) = scope { + if should_use_remote_secret_metadata(scope, &project, &service, server_id, json) { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); Box::new( stacker::console::commands::cli::secrets::SecretsListCommand::new_remote( scope, project, service, server_id, json, @@ -1600,9 +1672,37 @@ fn get_command( ) } } - SecretsCommands::Apps { project, json } => Box::new( - stacker::console::commands::cli::secrets::SecretsAppsCommand::new(project, json), - ), + SecretsCommands::Apps { + command, + project, + json, + } => match command { + Some(SecretsAppsCommands::Register { + service, + project: command_project, + json: command_json, + }) => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::register( + service, + command_project.or(project), + json || command_json, + ), + ), + Some(SecretsAppsCommands::Sync { + project: command_project, + json: command_json, + }) => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::sync( + command_project.or(project), + json || command_json, + ), + ), + None => Box::new( + stacker::console::commands::cli::secrets::SecretsAppsCommand::new( + project, json, + ), + ), + }, SecretsCommands::Delete { key, file, @@ -1611,7 +1711,10 @@ fn get_command( service, server_id, } => { - if let Some(scope) = scope { + if scope.is_some() || project.is_some() || service.is_some() || server_id.is_some() + { + let scope = inferred_remote_secret_scope(scope, &service, server_id) + .unwrap_or(RemoteSecretScope::Service); Box::new( stacker::console::commands::cli::secrets::SecretsDeleteCommand::new_remote( key, scope, project, service, server_id, @@ -2164,8 +2267,6 @@ mod tests { "secrets", "set", "S3_SECRET_KEY", - "--scope", - "service", "--project", "blog", "--service", @@ -2180,6 +2281,27 @@ mod tests { ); } + #[test] + fn test_secrets_set_still_parses_explicit_remote_service_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "set", + "S3_SECRET_KEY", + "--scope", + "service", + "--service", + "uploader", + "--body", + "supersecret", + ]); + + assert!( + parsed.is_ok(), + "explicit remote service scope should remain supported" + ); + } + #[test] fn test_secrets_set_parses_remote_server_flags() { let parsed = Cli::try_parse_from([ @@ -2207,8 +2329,6 @@ mod tests { "stacker", "secrets", "list", - "--scope", - "service", "--project", "blog", "--service", @@ -2222,6 +2342,41 @@ mod tests { ); } + #[test] + fn test_secrets_get_parses_service_without_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "get", + "S3_BUCKET", + "--service", + "upload", + "--json", + ]); + + assert!( + parsed.is_ok(), + "remote service get should infer service scope from --service" + ); + } + + #[test] + fn test_secrets_delete_parses_service_without_scope() { + let parsed = Cli::try_parse_from([ + "stacker", + "secrets", + "delete", + "S3_BUCKET", + "--service", + "upload", + ]); + + assert!( + parsed.is_ok(), + "remote service delete should infer service scope from --service" + ); + } + #[test] fn test_secrets_apps_parses_project_lookup_flags() { let parsed = Cli::try_parse_from(["stacker", "secrets", "apps", "--json"]); @@ -2232,6 +2387,27 @@ mod tests { ); } + #[test] + fn test_secrets_apps_parses_register_and_sync() { + let register = Cli::try_parse_from([ + "stacker", + "secrets", + "apps", + "register", + "upload", + "--project", + "blog", + "--json", + ]); + let sync = Cli::try_parse_from(["stacker", "secrets", "apps", "sync", "--json"]); + + assert!( + register.is_ok(), + "secrets apps register should parse successfully" + ); + assert!(sync.is_ok(), "secrets apps sync should parse successfully"); + } + #[test] fn test_secrets_help_mentions_remote_modes() { let mut command = Cli::command(); @@ -2271,7 +2447,7 @@ mod tests { let help = render_command_help(get); assert!(help.contains("metadata-only")); - assert!(help.contains("--scope service")); + assert!(help.contains("--scope ")); assert!(help.contains("--json")); } } diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 520ab32a..d415e495 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -63,6 +63,28 @@ pub struct ProjectAppInfo { pub parent_app_code: Option, } +/// Project app registration payload for `POST /project/{id}/apps`. +#[derive(Debug, Clone, Serialize)] +pub struct ProjectAppRegistrationRequest { + pub code: String, + pub name: Option, + pub image: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ports: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volumes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub depends_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deploy_order: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deployment_hash: Option, +} + /// Cloud credentials as returned by `/cloud` endpoints #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudInfo { @@ -569,6 +591,47 @@ impl StackerClient { Ok(api.list.unwrap_or_default()) } + /// Create or update one project app target. + pub async fn upsert_project_app( + &self, + project_id: i32, + request: &ProjectAppRegistrationRequest, + ) -> Result { + let body = + serde_json::to_value(request).map_err(|e| CliError::ConfigValidation(e.to_string()))?; + let resp = self + .send_project_request( + reqwest::Method::POST, + &format!("/{}/apps", project_id), + Some(&body), + "POST /project/{id}/apps", + ) + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::DeployFailed { + target: self.target, + reason: format!( + "Stacker server POST /project/{}/apps failed ({}): {}", + project_id, status, body + ), + }); + } + + let api: ApiResponse = + resp.json().await.map_err(|e| CliError::DeployFailed { + target: self.target, + reason: format!("Invalid response from Stacker server: {}", e), + })?; + + api.item.ok_or_else(|| CliError::DeployFailed { + target: self.target, + reason: "Stacker server did not return a project app".to_string(), + }) + } + // ── Deployments ─────────────────────────────────── /// List deployments for the authenticated user. diff --git a/src/console/commands/cli/secrets.rs b/src/console/commands/cli/secrets.rs index 5220e4fc..7f04b550 100644 --- a/src/console/commands/cli/secrets.rs +++ b/src/console/commands/cli/secrets.rs @@ -15,10 +15,12 @@ use std::fmt; use std::io::{self, IsTerminal, Read}; use std::path::{Path, PathBuf}; -use crate::cli::config_parser::StackerConfig; +use crate::cli::config_parser::{ServiceDefinition, StackerConfig}; use crate::cli::error::CliError; use crate::cli::runtime::CliRuntime; -use crate::cli::stacker_client::{ProjectAppInfo, ProjectInfo, RemoteSecretMetadataInfo}; +use crate::cli::stacker_client::{ + ProjectAppInfo, ProjectAppRegistrationRequest, ProjectInfo, RemoteSecretMetadataInfo, +}; use crate::console::commands::CallableTrait; use clap::ValueEnum; @@ -403,6 +405,149 @@ fn resolve_remote_service_code_from_apps( ))) } +fn available_project_app_codes(apps: &[ProjectAppInfo]) -> Vec { + let mut available_codes = apps + .iter() + .map(|app| app.code.clone()) + .collect::>(); + available_codes.sort(); + available_codes.dedup(); + available_codes +} + +fn local_service_names(config: &StackerConfig) -> Vec { + let mut names = config + .services + .iter() + .map(|service| service.name.clone()) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +fn find_local_service<'a>( + config: &'a StackerConfig, + requested: &str, +) -> Option<&'a ServiceDefinition> { + let requested_lower = requested.to_lowercase(); + config + .services + .iter() + .find(|service| service.name.to_lowercase() == requested_lower) +} + +fn service_registration_request( + service: &ServiceDefinition, + deployment_hash: Option, +) -> ProjectAppRegistrationRequest { + let env = if service.environment.is_empty() { + None + } else { + Some(serde_json::json!(service.environment)) + }; + let ports = if service.ports.is_empty() { + None + } else { + Some(serde_json::json!(service.ports)) + }; + let volumes = if service.volumes.is_empty() { + None + } else { + Some(serde_json::json!(service.volumes)) + }; + let depends_on = if service.depends_on.is_empty() { + None + } else { + Some(serde_json::json!(service.depends_on)) + }; + + ProjectAppRegistrationRequest { + code: service.name.clone(), + name: Some(service.name.clone()), + image: service.image.clone(), + env, + ports, + volumes, + depends_on, + enabled: Some(true), + deploy_order: None, + deployment_hash, + } +} + +fn active_deployment_hash(ctx: &CliRuntime, project: &ProjectInfo) -> Option { + ctx.block_on(ctx.client.agent_snapshot_by_project(project.id)) + .ok() + .map(|(_, hash)| hash) +} + +fn register_service_target( + ctx: &CliRuntime, + project: &ProjectInfo, + service: &ServiceDefinition, + operation: &str, +) -> Result { + let deployment_hash = active_deployment_hash(ctx, project); + let request = service_registration_request(service, deployment_hash); + ctx.block_on(ctx.client.upsert_project_app(project.id, &request)) + .map_err(|error| remap_remote_secret_error(operation, error)) +} + +fn register_local_service_target( + ctx: &CliRuntime, + project: &ProjectInfo, + requested: &str, + operation: &str, + remote_apps: &[ProjectAppInfo], +) -> Result { + let config_path = Path::new(DEFAULT_CONFIG_FILE); + let config = StackerConfig::from_file(config_path)?; + let Some(service) = find_local_service(&config, requested) else { + let local = local_service_names(&config); + let remote = available_project_app_codes(remote_apps); + return Err(CliError::ConfigValidation(format!( + "Unknown service target '{}'. Local services in stacker.yml: {}. Remote targets: {}.", + requested, + if local.is_empty() { + "(none)".to_string() + } else { + local.join(", ") + }, + if remote.is_empty() { + "(none)".to_string() + } else { + remote.join(", ") + } + ))); + }; + + register_service_target(ctx, project, service, operation) +} + +fn resolve_or_register_remote_service_code( + ctx: &CliRuntime, + project: &ProjectInfo, + requested: &str, + operation: &str, +) -> Result { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + + match resolve_remote_service_code_from_apps(&project.name, &apps, requested) { + Ok(code) => Ok(code), + Err(_) => { + let app = register_local_service_target(ctx, project, requested, operation, &apps)?; + eprintln!( + "✓ Registered remote secret target '{}' for project {}", + app.code, project.id + ); + Ok(app.code) + } + } +} + fn print_remote_secret(secret: &RemoteSecretMetadataInfo, json: bool) -> Result<(), CliError> { if json { let rendered = serde_json::to_string_pretty(secret) @@ -592,7 +737,8 @@ impl SecretsSetCommand { "Service-scoped secrets require --service".to_string(), ) })?; - let app_code = resolve_remote_service_code(&ctx, &project, app_code, operation)?; + let app_code = + resolve_or_register_remote_service_code(&ctx, &project, app_code, operation)?; let secret = ctx .block_on(ctx.client.set_service_secret( project.id, @@ -875,27 +1021,101 @@ impl CallableTrait for SecretsListCommand { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ pub struct SecretsAppsCommand { + action: SecretsAppsAction, project: Option, json: bool, } +enum SecretsAppsAction { + List, + Register { service: String }, + Sync, +} + impl SecretsAppsCommand { pub fn new(project: Option, json: bool) -> Self { - Self { project, json } + Self { + action: SecretsAppsAction::List, + project, + json, + } + } + + pub fn register(service: String, project: Option, json: bool) -> Self { + Self { + action: SecretsAppsAction::Register { service }, + project, + json, + } + } + + pub fn sync(project: Option, json: bool) -> Self { + Self { + action: SecretsAppsAction::Sync, + project, + json, + } + } + + fn print_registered_app(&self, app: &ProjectAppInfo) -> Result<(), CliError> { + if self.json { + let json = serde_json::to_string_pretty(app) + .map_err(|e| CliError::ConfigValidation(e.to_string()))?; + println!("{}", json); + } else { + println!( + "✓ Registered remote secret target {} (image: {})", + app.code, app.image + ); + } + Ok(()) } } impl CallableTrait for SecretsAppsCommand { fn call(&self) -> Result<(), Box> { - let operation = "remote project apps list"; + let operation = "remote project apps"; let ctx = CliRuntime::new(operation)?; let project_ref = resolve_service_project_reference(self.project.as_deref())?; let project = resolve_project(&ctx, &project_ref, operation)?; - let apps = ctx - .block_on(ctx.client.list_project_apps(project.id)) - .map_err(|error| remap_remote_secret_error(operation, error))?; - print_project_app_list(&apps, self.json)?; + match &self.action { + SecretsAppsAction::List => { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + print_project_app_list(&apps, self.json)?; + } + SecretsAppsAction::Register { service } => { + let apps = ctx + .block_on(ctx.client.list_project_apps(project.id)) + .map_err(|error| remap_remote_secret_error(operation, error))?; + let app = register_local_service_target(&ctx, &project, service, operation, &apps)?; + self.print_registered_app(&app)?; + } + SecretsAppsAction::Sync => { + let config = StackerConfig::from_file(Path::new(DEFAULT_CONFIG_FILE))?; + let mut registered = Vec::new(); + for service in &config.services { + registered.push(register_service_target(&ctx, &project, service, operation)?); + } + + if self.json { + let json = serde_json::to_string_pretty(®istered) + .map_err(|e| CliError::ConfigValidation(e.to_string()))?; + println!("{}", json); + } else { + println!( + "✓ Synced {} remote secret target(s) for project {}", + registered.len(), + project.id + ); + for app in registered { + println!("- {} ({})", app.code, app.image); + } + } + } + } Ok(()) } } @@ -1128,6 +1348,67 @@ mod tests { } } + fn service_definition(name: &str) -> ServiceDefinition { + ServiceDefinition { + name: name.to_string(), + image: "optimum/syncopia-upload:latest".to_string(), + ports: vec!["8000:8000".to_string()], + environment: std::collections::HashMap::from([( + "RUST_LOG".to_string(), + "info".to_string(), + )]), + volumes: vec!["upload-data:/data".to_string()], + depends_on: vec!["postgres".to_string()], + } + } + + #[test] + fn test_service_registration_request_maps_local_service() { + let service = service_definition("upload"); + let request = service_registration_request(&service, Some("deployment_abc".to_string())); + + assert_eq!(request.code, "upload"); + assert_eq!(request.name.as_deref(), Some("upload")); + assert_eq!(request.image, "optimum/syncopia-upload:latest"); + assert_eq!(request.enabled, Some(true)); + assert_eq!(request.deployment_hash.as_deref(), Some("deployment_abc")); + assert_eq!( + request.env.unwrap(), + serde_json::json!({"RUST_LOG": "info"}) + ); + assert_eq!(request.ports.unwrap(), serde_json::json!(["8000:8000"])); + assert_eq!( + request.volumes.unwrap(), + serde_json::json!(["upload-data:/data"]) + ); + assert_eq!(request.depends_on.unwrap(), serde_json::json!(["postgres"])); + } + + #[test] + fn test_find_local_service_matches_case_insensitively() { + let config = StackerConfig { + name: "syncopia".to_string(), + version: None, + organization: None, + project: Default::default(), + app: Default::default(), + services: vec![service_definition("upload")], + proxy: Default::default(), + deploy: Default::default(), + environments: Default::default(), + ai: Default::default(), + monitoring: Default::default(), + hooks: Default::default(), + env_file: None, + env: Default::default(), + }; + + assert_eq!( + find_local_service(&config, "UPLOAD").map(|service| service.name.as_str()), + Some("upload") + ); + } + // ── SECURITY: Path traversal via --file flag ────── // CWE-22: Improper Limitation of a Pathname to a Restricted Directory // From cca2ba8766d4643ce218f414e8a6148f7a668b83 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 12 May 2026 13:49:25 +0300 Subject: [PATCH 3/3] docs(website): restore release highlights Keep the existing pipe automation, agent ops, Kata isolation, and marketplace messaging while adding the v0.2.8 release highlights. Restore the Status Panel agent compose mount example, including /opt for managed installs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- website/dist/index.html | 42 +++++++++++++++++++++++++++++++++++++++++ website/src/index.html | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/website/dist/index.html b/website/dist/index.html index d64a7700..53918cc5 100644 --- a/website/dist/index.html +++ b/website/dist/index.html @@ -80,6 +80,10 @@

IP persistence, MCP secret tools, and hardened agent operations.

+ Pipe automation + Agent ops + Kata isolation + Marketplace flows Service secrets Cloud firewall IP retention @@ -223,6 +227,26 @@

Paused install recovery

Remote secret tools

AI clients can list remote secret targets and manage service secret metadata while plaintext values stay in Vault.

+
+ Automation +

Pipes

+

Create, activate, trigger, replay, and inspect pipe executions to move data between apps without building glue code first.

+
+
+ Operations +

Remote agent control

+

Health checks, logs, deploy-app, configure-proxy, and configure-firewall remain first-class CLI workflows for live environments.

+
+
+ Runtime +

Kata runtime isolation

+

Choose kata for stronger container isolation while keeping runc as the fast default for standard workloads.

+
+
+ Distribution +

Marketplace workflows

+

Submit stacks for review, track approval status, and inspect reviewer feedback directly from the CLI.

+

@@ -372,6 +396,24 @@

Open ports and keep your database private

✓ Restricted 5432/tcp to 10.0.0.0/8 ✓ Firewall rules persisted for future reboots
+
+
Agent compose essentials
+

Mount the host paths agent operations need

+
agent:
+  image: trydirect/status:unstable
+  container_name: statuspanel_agent
+  volumes:
+    # Agent needs Docker socket for container monitoring and logs
+    - /var/run/docker.sock:/var/run/docker.sock
+    # Mount docker CLI from host for deploy_app/remove_app commands
+    - /usr/bin/docker:/usr/bin/docker:ro
+    - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
+    # Mount host paths for compose, config files, and managed installs
+    - /home/trydirect:/home/trydirect
+    - /opt:/opt
+  env_file:
+    - .env
+

diff --git a/website/src/index.html b/website/src/index.html index d64a7700..53918cc5 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -80,6 +80,10 @@

IP persistence, MCP secret tools, and hardened agent operations.

+ Pipe automation + Agent ops + Kata isolation + Marketplace flows Service secrets Cloud firewall IP retention @@ -223,6 +227,26 @@

Paused install recovery

Remote secret tools

AI clients can list remote secret targets and manage service secret metadata while plaintext values stay in Vault.

+
+ Automation +

Pipes

+

Create, activate, trigger, replay, and inspect pipe executions to move data between apps without building glue code first.

+
+
+ Operations +

Remote agent control

+

Health checks, logs, deploy-app, configure-proxy, and configure-firewall remain first-class CLI workflows for live environments.

+
+
+ Runtime +

Kata runtime isolation

+

Choose kata for stronger container isolation while keeping runc as the fast default for standard workloads.

+
+
+ Distribution +

Marketplace workflows

+

Submit stacks for review, track approval status, and inspect reviewer feedback directly from the CLI.

+

@@ -372,6 +396,24 @@

Open ports and keep your database private

✓ Restricted 5432/tcp to 10.0.0.0/8 ✓ Firewall rules persisted for future reboots +
+
Agent compose essentials
+

Mount the host paths agent operations need

+
agent:
+  image: trydirect/status:unstable
+  container_name: statuspanel_agent
+  volumes:
+    # Agent needs Docker socket for container monitoring and logs
+    - /var/run/docker.sock:/var/run/docker.sock
+    # Mount docker CLI from host for deploy_app/remove_app commands
+    - /usr/bin/docker:/usr/bin/docker:ro
+    - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
+    # Mount host paths for compose, config files, and managed installs
+    - /home/trydirect:/home/trydirect
+    - /opt:/opt
+  env_file:
+    - .env
+