Add project-scoped OAuth attach flow
This commit is contained in:
@@ -683,8 +683,8 @@ def main(argv):
|
|||||||
print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]")
|
print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]")
|
||||||
print(" auth Auth commands: login-local, status.")
|
print(" auth Auth commands: login-local, status.")
|
||||||
print(" Example: nap auth login-local")
|
print(" Example: nap auth login-local")
|
||||||
print(" project Project commands: create, status, encryption.")
|
print(" project Project commands: init/attach existing, create, status, encryption.")
|
||||||
print(" Examples: nap project create, nap project status, nap project encryption status")
|
print(" Examples: nap project init, nap project create, nap project status")
|
||||||
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
|
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
|
||||||
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
|
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
|
||||||
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
|
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
|
||||||
|
|||||||
@@ -6593,29 +6593,58 @@ def oauth_loopback_authorize(args):
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_PROJECT_OAUTH_SCOPE = (
|
||||||
|
"openid profile email "
|
||||||
|
"napseer.projects.read "
|
||||||
|
"napseer.nodes.read napseer.nodes.write "
|
||||||
|
"napseer.locks.write "
|
||||||
|
"napseer.schedules.read napseer.schedules.write "
|
||||||
|
"napseer.agents.read napseer.agents.write "
|
||||||
|
"napseer.service_registrations.read napseer.service_registrations.write "
|
||||||
|
"napseer.encryption.read napseer.vault.read "
|
||||||
|
"napseer.chat.read napseer.chat.write "
|
||||||
|
"napseer.gateway.read napseer.gateway.connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def operator_loopback_login(args=None):
|
def operator_loopback_login(args=None):
|
||||||
args = args or {}
|
args = args or {}
|
||||||
result = oauth_loopback_authorize({
|
result = oauth_loopback_authorize({
|
||||||
**args,
|
**args,
|
||||||
"flow": "local_operator_access",
|
"flow": "local_operator_access",
|
||||||
"scope": "openid profile email napseer.feedback.read",
|
"scope": args.get("scope") or LOCAL_PROJECT_OAUTH_SCOPE,
|
||||||
})
|
})
|
||||||
|
project_id = result.get("project_id")
|
||||||
|
if not project_id:
|
||||||
|
raise RuntimeError("OAuth authorization did not return a selected project_id")
|
||||||
operator_auth = {
|
operator_auth = {
|
||||||
"base_url": result["api_base_url"],
|
"base_url": result["api_base_url"],
|
||||||
"token": result["access_token"],
|
"token": result["access_token"],
|
||||||
"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)),
|
||||||
"account_mode": "operator",
|
"project_id": project_id,
|
||||||
|
"account_mode": "operator_project",
|
||||||
"oauth_client_id": "nap-cli",
|
"oauth_client_id": "nap-cli",
|
||||||
"oauth_scope": result.get("scope"),
|
"oauth_scope": result.get("scope"),
|
||||||
}
|
}
|
||||||
save_auth_file_credentials(operator_auth)
|
save_auth_file_credentials(operator_auth)
|
||||||
|
try:
|
||||||
|
project = request_json("GET", f"/v1/projects/{project_id}")
|
||||||
|
save_auth_file_credentials({
|
||||||
|
"project_slug": project.get("slug"),
|
||||||
|
"project_name": project.get("name"),
|
||||||
|
"project_encryption_state": project.get("encryption_state"),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
project = None
|
||||||
return {
|
return {
|
||||||
"status": "authenticated",
|
"status": "authenticated",
|
||||||
"auth_path": str(AUTH_PATH),
|
"auth_path": str(AUTH_PATH),
|
||||||
"base_url": result["api_base_url"],
|
"base_url": result["api_base_url"],
|
||||||
|
"project_id": project_id,
|
||||||
|
"project": project,
|
||||||
"scope": result.get("scope"),
|
"scope": result.get("scope"),
|
||||||
"token_expires_at": operator_auth["token_expires_at"],
|
"token_expires_at": operator_auth["token_expires_at"],
|
||||||
"message": "OAuth2 PKCE loopback token stored in the repo-local Napseer auth state.",
|
"message": "OAuth2 PKCE loopback token and selected project stored in this folder's Napseer auth state.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -12275,6 +12304,15 @@ def cli_main(argv):
|
|||||||
return
|
return
|
||||||
if command == "project":
|
if command == "project":
|
||||||
subcommand = argv[2] if len(argv) > 2 else "help"
|
subcommand = argv[2] if len(argv) > 2 else "help"
|
||||||
|
if subcommand in {"init", "attach", "bootstrap-existing"}:
|
||||||
|
print(json.dumps(operator_loopback_login({
|
||||||
|
"frontend_url": cli_option(argv[3:], "--frontend-url", default=None),
|
||||||
|
"api_base_url": cli_option(argv[3:], "--api-base-url", "--base-url", default=None),
|
||||||
|
"open_browser": not cli_flag(argv[3:], "--no-browser"),
|
||||||
|
"timeout_seconds": int(cli_option(argv[3:], "--timeout", "--timeout-seconds", default="300")),
|
||||||
|
"port": cli_option(argv[3:], "--port", default=0),
|
||||||
|
}), indent=2))
|
||||||
|
return
|
||||||
if subcommand == "create":
|
if subcommand == "create":
|
||||||
print(json.dumps(cli_project_create(argv[3:]), indent=2))
|
print(json.dumps(cli_project_create(argv[3:]), indent=2))
|
||||||
return
|
return
|
||||||
@@ -12302,7 +12340,7 @@ def cli_main(argv):
|
|||||||
"passphrase": cli_option(argv[4:], "--passphrase", default=None),
|
"passphrase": cli_option(argv[4:], "--passphrase", default=None),
|
||||||
}), indent=2))
|
}), indent=2))
|
||||||
return
|
return
|
||||||
print("Usage: napseer_mcp_server.py project [create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]")
|
print("Usage: napseer_mcp_server.py project [init|attach [--no-browser] [--timeout SECONDS]|create [slug] [--name NAME] [--description TEXT] [--encryption plaintext]|claim [--no-browser] [--timeout SECONDS] [--return-url URL]|status|encryption [status|set plaintext|plaintext]]")
|
||||||
return
|
return
|
||||||
if command == "lineage":
|
if command == "lineage":
|
||||||
subcommand = argv[2] if len(argv) > 2 else "status"
|
subcommand = argv[2] if len(argv) > 2 else "status"
|
||||||
|
|||||||
@@ -683,8 +683,8 @@ def main(argv):
|
|||||||
print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]")
|
print("Usage: nap [auth|project|chat|plan|lineage|gateway|agent|feedback|status|update|where|install]")
|
||||||
print(" auth Auth commands: login-local, status.")
|
print(" auth Auth commands: login-local, status.")
|
||||||
print(" Example: nap auth login-local")
|
print(" Example: nap auth login-local")
|
||||||
print(" project Project commands: create, status, encryption.")
|
print(" project Project commands: init/attach existing, create, status, encryption.")
|
||||||
print(" Examples: nap project create, nap project status, nap project encryption status")
|
print(" Examples: nap project init, nap project create, nap project status")
|
||||||
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
|
print(" Encrypted project setup is completed by the local gateway: nap gateway vault process")
|
||||||
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
|
print(" chat Chat secret commands: secret status, secret setup, secret rotate.")
|
||||||
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
|
print(" Examples: nap chat secret setup --vault-passphrase TEXT, nap chat secret status")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke-test existing-project OAuth attachment for folder bootstrap."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
|
||||||
|
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_attach_test", script_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
mod = load_module()
|
||||||
|
saves = []
|
||||||
|
|
||||||
|
def save_auth(updates):
|
||||||
|
saves.append(dict(updates))
|
||||||
|
|
||||||
|
mod.oauth_loopback_authorize = lambda args: {
|
||||||
|
"api_base_url": "https://api.example.test",
|
||||||
|
"access_token": "oauth-token",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"scope": args["scope"],
|
||||||
|
"project_id": PROJECT_ID,
|
||||||
|
}
|
||||||
|
mod.save_auth_file_credentials = save_auth
|
||||||
|
mod.request_json = lambda method, path: {
|
||||||
|
"id": PROJECT_ID,
|
||||||
|
"slug": "existing-project",
|
||||||
|
"name": "Existing Project",
|
||||||
|
"encryption_state": "plaintext",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = mod.operator_loopback_login({"open_browser": False})
|
||||||
|
|
||||||
|
assert saves[0]["token"] == "oauth-token"
|
||||||
|
assert saves[0]["project_id"] == PROJECT_ID
|
||||||
|
assert saves[0]["account_mode"] == "operator_project"
|
||||||
|
assert "napseer.projects.read" in saves[0]["oauth_scope"]
|
||||||
|
assert saves[1]["project_slug"] == "existing-project"
|
||||||
|
assert result["project_id"] == PROJECT_ID
|
||||||
|
assert result["project"]["name"] == "Existing Project"
|
||||||
|
|
||||||
|
print("ok: project attach OAuth smoke passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
Reference in New Issue
Block a user