mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-19 06:10:07 +08:00
* feat: add per-user module permissions (RBAC) with admin bypass
Introduce a module-level permission system so non-admin users can be
scoped to a subset of dashboard modules. Adds a `permissions` TEXT column
on the `users` table (migration 006, SQLite + PostgreSQL), a central
permission catalog in `infra/users/permissions.py`, backend ACL checks
across routers, and dashboard gating via `RequirePermission` /
`ForbiddenPage` keyed by nav module. `admin` always bypasses every gate;
read access and chat/agent use are never gated.
* fix: gate connector install-cli by connectors permission (not admin-only)
The host CLI installer is a connectors-module operation and should be
gated by the `connectors` module permission, consistent with the rest of
the connector routes, instead of being admin-only.
The earlier RBAC change demoted this route from `current_admin` but the
test `test_install_cli_forbidden_for_non_admin` still failed because the
`create_user` test helper grants `BASELINE_PERMISSIONS` (which includes
`connectors`) by default, so the "non-admin" user actually held the
permission. Gate the route with `require_permission("connectors")` and
create the test user with `permissions=[]` so it genuinely lacks the
key and is rejected with 403.
---------
Co-authored-by: jubaoliang <jubaoliang@tencent.com>
196 lines
5.3 KiB
Python
196 lines
5.3 KiB
Python
"""Auth and bootstrap helpers for HTTP integration tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from octop.infra.setup.password_file import WIZARD_FILE_NAME, read_password
|
|
|
|
# Meets ``validate_password_policy`` (letter + digit, length ≥ 8).
|
|
TEST_PASSWORD = "TestPass12"
|
|
|
|
|
|
def _wizard_password_home(home: Path) -> Path:
|
|
"""Resolve wizard password dir: file lives at ``~/octop-login.txt``, not under ``~/.octop/``."""
|
|
for cand in (home, home.parent, Path.home()):
|
|
if (cand / WIZARD_FILE_NAME).exists():
|
|
return cand
|
|
return home
|
|
|
|
|
|
async def wizard_token(client: httpx.AsyncClient, home: Path) -> str:
|
|
pw = read_password(_wizard_password_home(home))
|
|
assert pw is not None
|
|
r = await client.post("/api/setup/verify-password", json={"password": pw})
|
|
r.raise_for_status()
|
|
return r.json()["wizard_token"]
|
|
|
|
|
|
async def bootstrap_admin(
|
|
client: httpx.AsyncClient,
|
|
home: Path,
|
|
*,
|
|
username: str = "admin",
|
|
password: str = TEST_PASSWORD,
|
|
) -> httpx.Response:
|
|
"""Run verify-password → initial-admin → finish (creates default ``main`` agent)."""
|
|
pw = read_password(_wizard_password_home(home))
|
|
assert pw is not None, "wizard password file missing"
|
|
tok_resp = await client.post("/api/setup/verify-password", json={"password": pw})
|
|
tok = tok_resp.json()["wizard_token"]
|
|
r = await client.post(
|
|
"/api/setup/initial-admin",
|
|
json={"username": username, "password": password},
|
|
headers={"Authorization": f"Bearer {tok}"},
|
|
)
|
|
r.raise_for_status()
|
|
finish = await client.post(
|
|
"/api/setup/finish",
|
|
json={"provider_draft": None},
|
|
headers={"Authorization": f"Bearer {tok}"},
|
|
)
|
|
finish.raise_for_status()
|
|
return r
|
|
|
|
|
|
async def login(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
username: str = "admin",
|
|
password: str = TEST_PASSWORD,
|
|
) -> str:
|
|
r = await client.post("/api/auth/login", json={"username": username, "password": password})
|
|
r.raise_for_status()
|
|
return r.json()["access_token"]
|
|
|
|
|
|
def bearer(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
async def auth_header(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
username: str = "admin",
|
|
password: str = TEST_PASSWORD,
|
|
) -> dict[str, str]:
|
|
return bearer(await login(client, username=username, password=password))
|
|
|
|
|
|
async def seed_openai_provider(
|
|
client: httpx.AsyncClient,
|
|
auth: dict[str, str],
|
|
*,
|
|
name: str = "openai",
|
|
api_key: str = "k",
|
|
) -> None:
|
|
r = await client.post(
|
|
"/api/admin/providers",
|
|
headers=auth,
|
|
json={
|
|
"name": name,
|
|
"kind": "openai",
|
|
"base_url": "https://api.openai.com/v1",
|
|
"api_key": api_key,
|
|
"models": [
|
|
{
|
|
"id": "gpt-4o",
|
|
"name": "GPT-4o",
|
|
"enabled": True,
|
|
"input": ["text"],
|
|
"thinking": False,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
r.raise_for_status()
|
|
|
|
|
|
async def create_agent(
|
|
client: httpx.AsyncClient,
|
|
auth: dict[str, str],
|
|
*,
|
|
name: str = "bot",
|
|
config: dict[str, Any] | None = None,
|
|
) -> str:
|
|
body = {
|
|
"name": name,
|
|
"config": config or {"providers": ["openai"], "default_model": "openai:gpt-4o"},
|
|
}
|
|
r = await client.post("/api/agents", headers=auth, json=body)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
return data.get("agent_id") or data["id"]
|
|
|
|
|
|
async def create_user(
|
|
client: httpx.AsyncClient,
|
|
admin_auth: dict[str, str],
|
|
*,
|
|
username: str,
|
|
password: str = TEST_PASSWORD,
|
|
role: str = "user",
|
|
permissions: list[str] | None = None,
|
|
) -> dict[str, str]:
|
|
from octop.infra.users.permissions import BASELINE_PERMISSIONS
|
|
|
|
body: dict[str, object] = {
|
|
"username": username,
|
|
"password": password,
|
|
"role": role,
|
|
"permissions": (
|
|
list(permissions) if permissions is not None else sorted(BASELINE_PERMISSIONS)
|
|
),
|
|
}
|
|
r = await client.post(
|
|
"/api/users",
|
|
headers=admin_auth,
|
|
json=body,
|
|
)
|
|
r.raise_for_status()
|
|
return await auth_header(client, username=username, password=password)
|
|
|
|
|
|
async def ensure_users(
|
|
client: httpx.AsyncClient,
|
|
admin_auth: dict[str, str],
|
|
*usernames: str,
|
|
password: str = TEST_PASSWORD,
|
|
) -> dict[str, dict[str, str]]:
|
|
auths: dict[str, dict[str, str]] = {}
|
|
for username in usernames:
|
|
auths[username] = await create_user(
|
|
client,
|
|
admin_auth,
|
|
username=username,
|
|
password=password,
|
|
)
|
|
return auths
|
|
|
|
|
|
async def resolve_user_id(
|
|
client: httpx.AsyncClient,
|
|
admin_auth: dict[str, str],
|
|
username: str,
|
|
) -> int:
|
|
users = (await client.get("/api/users", headers=admin_auth)).json()
|
|
return next(u["id"] for u in users if u["username"] == username)
|
|
|
|
|
|
async def create_provider(
|
|
client: httpx.AsyncClient,
|
|
auth: dict[str, str],
|
|
*,
|
|
name: str = "openai",
|
|
kind: str = "openai",
|
|
api_key: str = "k",
|
|
**extra: Any,
|
|
) -> dict[str, Any]:
|
|
body: dict[str, Any] = {"name": name, "kind": kind, "api_key": api_key, **extra}
|
|
r = await client.post("/api/admin/providers", headers=auth, json=body)
|
|
r.raise_for_status()
|
|
return r.json()
|