fix: preserve private upload settings on token rotation

This commit is contained in:
nac
2026-07-13 02:09:09 +00:00
parent dae2784e2b
commit 546da0ad45
3 changed files with 72 additions and 6 deletions
+14 -6
View File
@@ -80,18 +80,26 @@ HTTP write mode also requires these server-side NocoBase credentials. Do not put
- `NOCOBASE_MCP_UPLOAD_API_BASE_URL` (for example `https://nocobase.example/api`) - `NOCOBASE_MCP_UPLOAD_API_BASE_URL` (for example `https://nocobase.example/api`)
- `NOCOBASE_MCP_UPLOAD_JWT` (a NocoBase user JWT) - `NOCOBASE_MCP_UPLOAD_JWT` (a NocoBase user JWT)
`scripts/mcp_service.sh start` creates `.runtime/nocobase-crm-mcp.env` with mode `0600`, its parent directory with mode `0700`, and only the non-secret upload size setting. Add the API URL and JWT to that private env file before starting or restarting the managed service. HTTP write mode fails at startup with the missing variable name when either credential is absent. `scripts/mcp_service.sh start` creates `.runtime/nocobase-crm-mcp.env` with mode `0600`, its parent directory with mode `0700`, and only the non-secret upload size setting. HTTP write mode fails at startup with the missing variable name when either credential is absent.
Operational flow: First-install flow:
```bash ```bash
NOCOBASE_MCP_UPLOAD_API_BASE_URL='https://nocobase.example/api' # Creates the private env file; the service is expected to fail until credentials are added.
NOCOBASE_MCP_UPLOAD_JWT='<user-jwt>' bash scripts/mcp_service.sh start
# Add server-side NocoBase credentials to the systemd EnvironmentFile.
cat >> .runtime/nocobase-crm-mcp.env <<'EOF'
NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nocobase.example/api
NOCOBASE_MCP_UPLOAD_JWT=<user-jwt>
EOF
# Start the service with the newly configured private env file.
bash scripts/mcp_service.sh start bash scripts/mcp_service.sh start
curl -X POST -H "Authorization: Bearer <mcp-token>" -F "file=@quote.pdf" http://<host>:10087/uploads curl -X POST -H "Authorization: Bearer <mcp-token>" -F "file=@quote.pdf" http://<host>:10087/uploads
``` ```
Set the first two values in `.runtime/nocobase-crm-mcp.env` (rather than in client configuration) before the service starts. An uploaded attachment that is not yet associated with a project is intentionally retained; this release does not auto-clean orphan attachments. It also provides no delete operation for projects or attachments. The NocoBase credentials belong in `.runtime/nocobase-crm-mcp.env`, not in shell-local variables or client configuration. Token rotation preserves these operator-managed env lines. An uploaded attachment that is not yet associated with a project is intentionally retained; this release does not auto-clean orphan attachments. It also provides no delete operation for projects or attachments.
## Permissions ## Permissions
@@ -141,7 +149,7 @@ For a managed user-level systemd service:
bash scripts/mcp_service.sh start bash scripts/mcp_service.sh start
``` ```
The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token and the non-secret 25 MiB upload size setting automatically. Before the managed HTTP service can start, add `NOCOBASE_MCP_UPLOAD_API_BASE_URL` and `NOCOBASE_MCP_UPLOAD_JWT` to that private file. Use `new-token` only when rotating the token later. The first `start` creates `.runtime/nocobase-crm-mcp.env` with a random bearer token and the non-secret 25 MiB upload size setting automatically, then fails until the operator adds `NOCOBASE_MCP_UPLOAD_API_BASE_URL` and `NOCOBASE_MCP_UPLOAD_JWT` to that private file. Run `start` again after adding them. Use `new-token` only when rotating the token later; it preserves operator-managed env lines.
Show the saved URL/header/client config: Show the saved URL/header/client config:
+28
View File
@@ -27,6 +27,17 @@ DEFAULT_CONFIG_PATH = "config.json"
DEFAULT_TOKEN_BYTES = 32 DEFAULT_TOKEN_BYTES = 32
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
MANAGED_ENV_NAMES = frozenset(
{
"NOCOBASE_MCP_TOKEN",
"NOCOBASE_MCP_HOST",
"NOCOBASE_MCP_PORT",
"NOCOBASE_MCP_PATH",
"NOCOBASE_MCP_ENV",
"NOCOBASE_MCP_CONFIG",
"NOCOBASE_MCP_UPLOAD_MAX_BYTES",
}
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -116,6 +127,7 @@ def write_token_env_file(
nb_env: str = DEFAULT_NB_ENV, nb_env: str = DEFAULT_NB_ENV,
config_path: str = DEFAULT_CONFIG_PATH, config_path: str = DEFAULT_CONFIG_PATH,
) -> None: ) -> None:
preserved_lines = _operator_env_lines(env_path)
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.parent.chmod(0o700) env_path.parent.chmod(0o700)
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
@@ -127,9 +139,25 @@ def write_token_env_file(
env_file.write(f"NOCOBASE_MCP_ENV={nb_env}\n") env_file.write(f"NOCOBASE_MCP_ENV={nb_env}\n")
env_file.write(f"NOCOBASE_MCP_CONFIG={config_path}\n") env_file.write(f"NOCOBASE_MCP_CONFIG={config_path}\n")
env_file.write(f"NOCOBASE_MCP_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n") env_file.write(f"NOCOBASE_MCP_UPLOAD_MAX_BYTES={DEFAULT_UPLOAD_MAX_BYTES}\n")
if preserved_lines:
env_file.write("\n")
env_file.write("\n".join(preserved_lines))
env_file.write("\n")
env_path.chmod(0o600) env_path.chmod(0o600)
def _operator_env_lines(env_path: Path) -> list[str]:
if not env_path.exists():
return []
preserved_lines: list[str] = []
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
key, separator, _value = raw_line.strip().partition("=")
if separator and key.strip() in MANAGED_ENV_NAMES:
continue
preserved_lines.append(raw_line)
return preserved_lines
def read_service_env_file(env_path: Path) -> dict[str, str]: def read_service_env_file(env_path: Path) -> dict[str, str]:
if not env_path.exists(): if not env_path.exists():
raise RuntimeError( raise RuntimeError(
+30
View File
@@ -122,6 +122,36 @@ class McpServiceConfigTests(unittest.TestCase):
self.assertIn("http://10.0.0.2:10087/mcp", output) self.assertIn("http://10.0.0.2:10087/mcp", output)
run_systemctl.assert_not_called() run_systemctl.assert_not_called()
def test_new_token_preserves_operator_upload_settings_and_other_env_lines(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project"
project_root.mkdir()
config_home = Path(tmp) / "config"
env_path = project_root / ".runtime" / "nocobase-crm-mcp.env"
manage_mcp_service.write_token_env_file(env_path, "old-token")
with env_path.open("a", encoding="utf-8") as env_file:
env_file.write("# operator-managed upload credentials\n")
env_file.write("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n")
env_file.write("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n")
env_file.write("EXTRA_OPERATOR_SETTING=preserve-me\n")
with (
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
patch.object(manage_mcp_service, "generate_token", return_value="new-token"),
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=[]),
patch.object(manage_mcp_service, "run_systemctl") as run_systemctl,
):
manage_mcp_service.new_token(project_root=project_root)
content = env_path.read_text(encoding="utf-8")
self.assertIn("NOCOBASE_MCP_TOKEN=new-token\n", content)
self.assertIn("# operator-managed upload credentials\n", content)
self.assertIn("NOCOBASE_MCP_UPLOAD_API_BASE_URL=https://nb.example/api\n", content)
self.assertIn("NOCOBASE_MCP_UPLOAD_JWT=operator-jwt\n", content)
self.assertIn("EXTRA_OPERATOR_SETTING=preserve-me\n", content)
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
run_systemctl.assert_not_called()
def test_start_service_uses_saved_token_and_restarts_service(self) -> None: def test_start_service_uses_saved_token_and_restarts_service(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
project_root = Path(tmp) / "project" project_root = Path(tmp) / "project"