fix: preserve private upload settings on token rotation
This commit is contained in:
@@ -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_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
|
||||
NOCOBASE_MCP_UPLOAD_API_BASE_URL='https://nocobase.example/api'
|
||||
NOCOBASE_MCP_UPLOAD_JWT='<user-jwt>'
|
||||
# Creates the private env file; the service is expected to fail until credentials are added.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -141,7 +149,7 @@ For a managed user-level systemd service:
|
||||
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:
|
||||
|
||||
|
||||
@@ -27,6 +27,17 @@ DEFAULT_CONFIG_PATH = "config.json"
|
||||
DEFAULT_TOKEN_BYTES = 32
|
||||
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
|
||||
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)
|
||||
@@ -116,6 +127,7 @@ def write_token_env_file(
|
||||
nb_env: str = DEFAULT_NB_ENV,
|
||||
config_path: str = DEFAULT_CONFIG_PATH,
|
||||
) -> None:
|
||||
preserved_lines = _operator_env_lines(env_path)
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.parent.chmod(0o700)
|
||||
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_CONFIG={config_path}\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)
|
||||
|
||||
|
||||
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]:
|
||||
if not env_path.exists():
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -122,6 +122,36 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("http://10.0.0.2:10087/mcp", output)
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
|
||||
Reference in New Issue
Block a user