Fix account login project creation lifecycle
This commit is contained in:
@@ -58,7 +58,7 @@ RUNTIME_SCRIPT_NAMES = (
|
|||||||
"napseer_mcp_supervisor.py",
|
"napseer_mcp_supervisor.py",
|
||||||
)
|
)
|
||||||
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
|
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
|
||||||
CLI_RELEASE_VERSION = "0.2.1"
|
CLI_RELEASE_VERSION = "0.2.2"
|
||||||
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
|
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-08-01"
|
||||||
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
|
CLI_MINIMUM_CONTRACT_VERSION = "2026-05-19"
|
||||||
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
|
CLI_BUNDLE_SCHEMA_VERSION = "napseer.cli.bundle.v1"
|
||||||
|
|||||||
@@ -7025,13 +7025,23 @@ LOCAL_PROJECT_OAUTH_SCOPE = (
|
|||||||
"napseer.gateway.read napseer.gateway.connect"
|
"napseer.gateway.read napseer.gateway.connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Account login is also the first step of the new-project lifecycle. It needs
|
||||||
|
# project creation authority plus the capabilities that will be used after the
|
||||||
|
# newly created project is bound to this directory. Existing-project attach
|
||||||
|
# remains narrower and receives a server-bound project token.
|
||||||
|
OPERATOR_ACCOUNT_OAUTH_SCOPE = LOCAL_PROJECT_OAUTH_SCOPE.replace(
|
||||||
|
"napseer.projects.read ",
|
||||||
|
"napseer.projects.read napseer.projects.write ",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def operator_account_login(args=None):
|
def operator_account_login(args=None):
|
||||||
args = args or {}
|
args = args or {}
|
||||||
result = oauth_loopback_authorize({
|
result = oauth_loopback_authorize({
|
||||||
**args,
|
**args,
|
||||||
"flow": "operator_login",
|
"flow": "operator_login",
|
||||||
"scope": args.get("scope") or "openid profile email napseer.projects.read",
|
"scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE,
|
||||||
})
|
})
|
||||||
token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0))
|
token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0))
|
||||||
replace_public_auth_state(
|
replace_public_auth_state(
|
||||||
@@ -7054,7 +7064,10 @@ def operator_account_login(args=None):
|
|||||||
"base_url": result.get("api_base_url") or BASE_URL,
|
"base_url": result.get("api_base_url") or BASE_URL,
|
||||||
"scope": result.get("scope"),
|
"scope": result.get("scope"),
|
||||||
"token_expires_at": token_expires_at,
|
"token_expires_at": token_expires_at,
|
||||||
"message": "Account authenticated without a project binding. Run `nap project attach` to select a project for this directory.",
|
"message": (
|
||||||
|
"Account authenticated without a project binding. Run `nap project attach` "
|
||||||
|
"for an existing project or `nap project create` for a new one."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7551,6 +7564,18 @@ def current_project_id():
|
|||||||
|
|
||||||
def create_project_with_state(args):
|
def create_project_with_state(args):
|
||||||
ensure_enrolled(args)
|
ensure_enrolled(args)
|
||||||
|
refresh_public_auth_state()
|
||||||
|
if (
|
||||||
|
AUTH.get("account_mode") == "operator_account"
|
||||||
|
and AUTH.get("oauth_scope")
|
||||||
|
and "napseer.projects.write" not in str(AUTH.get("oauth_scope")).split()
|
||||||
|
):
|
||||||
|
raise SafeToolError(
|
||||||
|
"project_create_scope_required",
|
||||||
|
"This account login predates project-create permission. Run `nap auth login` "
|
||||||
|
"again, then retry `nap project create`.",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"slug": args["slug"],
|
"slug": args["slug"],
|
||||||
"name": args["name"],
|
"name": args["name"],
|
||||||
@@ -7560,21 +7585,30 @@ def create_project_with_state(args):
|
|||||||
try:
|
try:
|
||||||
project = request_json("POST", "/v1/projects", payload)
|
project = request_json("POST", "/v1/projects", payload)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
|
if safe_error_matches(exc, status=403, service_code="insufficient_scope"):
|
||||||
|
raise SafeToolError(
|
||||||
|
"project_create_scope_required",
|
||||||
|
"Project creation is not authorized by this account login. Run `nap auth login` "
|
||||||
|
"again, then retry `nap project create`.",
|
||||||
|
status=403,
|
||||||
|
service_code="insufficient_scope",
|
||||||
|
) from exc
|
||||||
if "HTTP 409" not in str(exc):
|
if "HTTP 409" not in str(exc):
|
||||||
raise
|
raise
|
||||||
project = find_project_by_slug(args["slug"])
|
project = find_project_by_slug(args["slug"])
|
||||||
if not project:
|
if not project:
|
||||||
raise
|
raise
|
||||||
status = "existing"
|
status = "existing"
|
||||||
save_auth(
|
auth_updates = {
|
||||||
{
|
|
||||||
"base_url": BASE_URL,
|
"base_url": BASE_URL,
|
||||||
"token": TOKEN,
|
"token": TOKEN,
|
||||||
"project_id": project["id"],
|
"project_id": project["id"],
|
||||||
"project_slug": project["slug"],
|
"project_slug": project["slug"],
|
||||||
"project_name": project["name"],
|
"project_name": project["name"],
|
||||||
}
|
}
|
||||||
)
|
if AUTH.get("account_mode") == "operator_account":
|
||||||
|
auth_updates["account_mode"] = "operator_project"
|
||||||
|
save_auth(auth_updates)
|
||||||
try:
|
try:
|
||||||
register_project_signing_key({"label": "local-project-key"})
|
register_project_signing_key({"label": "local-project-key"})
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -8119,7 +8153,8 @@ def cli_configure_status():
|
|||||||
"Project is configured for this directory."
|
"Project is configured for this directory."
|
||||||
if project_configured
|
if project_configured
|
||||||
else (
|
else (
|
||||||
"Account authenticated; no project is attached. Run `nap project attach`."
|
"Account authenticated; no project is attached. Run `nap project attach` "
|
||||||
|
"for an existing project or `nap project create` for a new one."
|
||||||
if account_only
|
if account_only
|
||||||
else "No account or project is configured for this directory."
|
else "No account or project is configured for this directory."
|
||||||
)
|
)
|
||||||
@@ -14866,7 +14901,7 @@ def cli_main(argv):
|
|||||||
nap auth repair
|
nap auth repair
|
||||||
|
|
||||||
`login` signs in to the account only. It does not select a project.
|
`login` signs in to the account only. It does not select a project.
|
||||||
Use `nap project attach` to bind this folder to an existing project.
|
Use `nap project attach` for an existing project or `nap project create` for a new one.
|
||||||
Refresh is automatic during authenticated calls; use `repair` only for recovery.
|
Refresh is automatic during authenticated calls; use `repair` only for recovery.
|
||||||
`login-local` remains a hidden compatibility alias for `login`.""")
|
`login-local` remains a hidden compatibility alias for `login`.""")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ def test_bundle_install_stages_validates_and_activates_atomically(tmp_path, monk
|
|||||||
|
|
||||||
result = module.install_assets()
|
result = module.install_assets()
|
||||||
|
|
||||||
assert result["cli_distribution"]["release_version"] == "0.2.1"
|
assert result["cli_distribution"]["release_version"] == "0.2.2"
|
||||||
assert (install_dir / "current").is_symlink()
|
assert (install_dir / "current").is_symlink()
|
||||||
assert (install_dir / "current" / "bundle-manifest.json").is_file()
|
assert (install_dir / "current" / "bundle-manifest.json").is_file()
|
||||||
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
|
assert (install_dir / "current" / "napseer_mcp_server.py").is_file()
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ def run():
|
|||||||
assert saves[0]["refresh_token"] == "account-refresh-token"
|
assert saves[0]["refresh_token"] == "account-refresh-token"
|
||||||
assert saves[0]["account_mode"] == "operator_account"
|
assert saves[0]["account_mode"] == "operator_account"
|
||||||
assert "project_id" not in saves[0]
|
assert "project_id" not in saves[0]
|
||||||
|
assert "napseer.projects.write" in saves[0]["oauth_scope"]
|
||||||
assert result["mode"] == "operator_account"
|
assert result["mode"] == "operator_account"
|
||||||
|
assert "nap project create" in result["message"]
|
||||||
|
|
||||||
print("ok: project attach OAuth smoke passed")
|
print("ok: project attach OAuth smoke passed")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for account-login -> project-create authorization."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def load_module():
|
||||||
|
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
||||||
|
sys.path.insert(0, str(script_path.parent))
|
||||||
|
spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_create_oauth_test", script_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def configure_account_state(mod, scope):
|
||||||
|
mod.AUTH = {
|
||||||
|
"account_mode": "operator_account",
|
||||||
|
"oauth_scope": scope,
|
||||||
|
}
|
||||||
|
mod.TOKEN = "test-token"
|
||||||
|
mod.BASE_URL = "https://api.example.test"
|
||||||
|
mod.ensure_enrolled = lambda args: {"status": "already_enrolled"}
|
||||||
|
mod.refresh_public_auth_state = lambda: mod.AUTH
|
||||||
|
mod.register_project_signing_key = lambda args: None
|
||||||
|
|
||||||
|
|
||||||
|
def project_args():
|
||||||
|
return {
|
||||||
|
"slug": "created-project",
|
||||||
|
"name": "Created Project",
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_login_requests_create_and_runtime_scopes():
|
||||||
|
mod = load_module()
|
||||||
|
captured = []
|
||||||
|
saves = []
|
||||||
|
mod.AUTH_PATH = pathlib.Path("/tmp/napseer-test/auth.json")
|
||||||
|
mod.oauth_loopback_authorize = lambda args: (
|
||||||
|
captured.append(dict(args))
|
||||||
|
or {
|
||||||
|
"api_base_url": "https://api.example.test",
|
||||||
|
"access_token": "test-token",
|
||||||
|
"refresh_token": "test-refresh-token",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"scope": args["scope"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mod.replace_public_auth_state = lambda updates, clear_keys=(): saves.append(dict(updates))
|
||||||
|
|
||||||
|
result = mod.operator_account_login({"open_browser": False})
|
||||||
|
|
||||||
|
requested = set(captured[0]["scope"].split())
|
||||||
|
assert "napseer.projects.write" in requested
|
||||||
|
assert set(mod.LOCAL_PROJECT_OAUTH_SCOPE.split()).issubset(requested)
|
||||||
|
assert saves[0]["account_mode"] == "operator_account"
|
||||||
|
assert "nap project create" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_account_scope_fails_before_http_with_recovery():
|
||||||
|
mod = load_module()
|
||||||
|
configure_account_state(mod, "openid profile email napseer.projects.read")
|
||||||
|
requests = []
|
||||||
|
mod.request_json = lambda *args, **kwargs: requests.append((args, kwargs))
|
||||||
|
|
||||||
|
try:
|
||||||
|
mod.create_project_with_state(project_args())
|
||||||
|
except mod.SafeToolError as exc:
|
||||||
|
assert exc.code == "project_create_scope_required"
|
||||||
|
assert exc.status == 403
|
||||||
|
assert "nap auth login" in exc.safe_message
|
||||||
|
else:
|
||||||
|
raise AssertionError("legacy account scope should require a new login")
|
||||||
|
|
||||||
|
assert not requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_create_transitions_local_state_to_project_mode():
|
||||||
|
mod = load_module()
|
||||||
|
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
|
||||||
|
saves = []
|
||||||
|
mod.request_json = lambda method, path, payload=None: {
|
||||||
|
"id": PROJECT_ID,
|
||||||
|
"slug": payload["slug"],
|
||||||
|
"name": payload["name"],
|
||||||
|
}
|
||||||
|
mod.save_auth = lambda updates: saves.append(dict(updates))
|
||||||
|
|
||||||
|
project, status, _message = mod.create_project_with_state(project_args())
|
||||||
|
|
||||||
|
assert status == "created"
|
||||||
|
assert project["id"] == PROJECT_ID
|
||||||
|
assert saves[0]["project_id"] == PROJECT_ID
|
||||||
|
assert saves[0]["account_mode"] == "operator_project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_scope_denial_is_actionable():
|
||||||
|
mod = load_module()
|
||||||
|
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
|
||||||
|
|
||||||
|
def denied(*args, **kwargs):
|
||||||
|
raise mod.SafeToolError(
|
||||||
|
"http_403",
|
||||||
|
"Napseer POST request failed with HTTP 403.",
|
||||||
|
status=403,
|
||||||
|
service_code="insufficient_scope",
|
||||||
|
)
|
||||||
|
|
||||||
|
mod.request_json = denied
|
||||||
|
|
||||||
|
try:
|
||||||
|
mod.create_project_with_state(project_args())
|
||||||
|
except mod.SafeToolError as exc:
|
||||||
|
assert exc.code == "project_create_scope_required"
|
||||||
|
assert "nap auth login" in exc.safe_message
|
||||||
|
else:
|
||||||
|
raise AssertionError("scope denial should return an actionable error")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_account_login_requests_create_and_runtime_scopes()
|
||||||
|
test_legacy_account_scope_fails_before_http_with_recovery()
|
||||||
|
test_successful_create_transitions_local_state_to_project_mode()
|
||||||
|
test_server_scope_denial_is_actionable()
|
||||||
|
print("ok: project create OAuth lifecycle passed")
|
||||||
Reference in New Issue
Block a user