Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/dashboard/src/pages/Compute/NodeDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ describe("NodeDetail", () => {
});
});

// A shell on a worker is a root shell, so it needs node:shell, not just
// the deployment permissions every member holds.
it("hides the Shell tab and redirects a deep link without node:shell", async () => {
const authCtx = await import("@/context/AuthContext");
(authCtx.useAuth as ReturnType<typeof vi.fn>).mockReturnValue({
hasPermission: (p: string) => p !== "node:shell",
user: { user_id: "u1", username: "test", permissions: [] },
organizations: [],
});

renderNodeDetail("/dashboard/compute/pools/p1/nodes/n1/shell");

await waitFor(() => {
expect(screen.getAllByText("worker-1").length).toBeGreaterThan(0);
});
expect(screen.queryByText("Shell")).not.toBeInTheDocument();
expect(screen.queryByTestId("node-shell-stub")).not.toBeInTheDocument();
});

// ── Logs tab ──────────────────────────────────────────────────────────────
it("shows NodeLogs stub on logs tab", async () => {
const user = userEvent.setup();
Expand Down
23 changes: 14 additions & 9 deletions apps/dashboard/src/pages/Compute/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default function NodeDetail() {
const { hasPermission } = useAuth();
const canRetry = hasPermission("deployment:create");
const canDelete = hasPermission("deployment:delete");
const canShell = hasPermission("node:shell");

const [node, setNode] = useState<NodeView | null>(null);
const [summary, setSummary] = useState<ProvisioningSummary | null>(null);
Expand Down Expand Up @@ -188,7 +189,7 @@ export default function NodeDetail() {
{ label: "Instance Details", value: "instance-details", hidden: !isNosana },
{ label: "EC2 Details", value: "ec2", hidden: !isAws },
{ label: "Metrics", value: "metrics" },
{ label: "Shell", value: "shell", hidden: isDepin },
{ label: "Shell", value: "shell", hidden: isDepin || !canShell },
{ label: "Logs", value: "logs" },
];

Expand Down Expand Up @@ -377,14 +378,18 @@ export default function NodeDetail() {
<Route
path="shell"
element={
<NodeTabLayout tabs={tabs} activeTab="shell" poolId={poolId} nid={nid}>
<NodeShell
nodeId={node.id}
nodeProvider={node.provider ?? undefined}
nodeState={node.state}
currentPhase={summary?.current_phase}
/>
</NodeTabLayout>
canShell ? (
<NodeTabLayout tabs={tabs} activeTab="shell" poolId={poolId} nid={nid}>
<NodeShell
nodeId={node.id}
nodeProvider={node.provider ?? undefined}
nodeState={node.state}
currentPhase={summary?.current_phase}
/>
</NodeTabLayout>
) : (
<Navigate to="../provisioning" replace />
)
}
/>

Expand Down
28 changes: 28 additions & 0 deletions apps/dashboard/src/pages/Compute/PoolDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,34 @@ describe("PoolDetail", () => {
expect(screen.queryByTestId("delete-node-node-abc")).not.toBeInTheDocument();
});

// A shell on a worker is a root shell, so only node:shell holders see it.
it.each([
[true, true],
[false, false],
])("shows the per-row Shell link only with node:shell (has it: %s)", async (granted, shown) => {
const authCtx = await import("@/context/AuthContext");
(authCtx.useAuth as ReturnType<typeof vi.fn>).mockReturnValue({
hasPermission: (p: string) => (p === "node:shell" ? granted : true),
user: { org_id: "org-1", user_id: "u1", username: "test", permissions: [] },
organizations: [],
});

const user = userEvent.setup();
const nodeService = await import("@/services/nodeService");
(nodeService.listNodes as ReturnType<typeof vi.fn>).mockResolvedValue([MOCK_NODE]);

renderPoolDetail();
await waitForPoolLoad();
await user.click(screen.getByRole("button", { name: "Nodes" }));

await waitFor(() => {
expect(screen.getByText("worker-1")).toBeInTheDocument();
});
const link = screen.queryByRole("link", { name: /shell/i });
if (shown) expect(link).toBeInTheDocument();
else expect(link).not.toBeInTheDocument();
});

// ── Per-row Delete: confirm true → deleteNode called + node list refetched ─
it("calls deleteNode then refetches the node list on a confirmed row delete", async () => {
const user = userEvent.setup();
Expand Down
20 changes: 13 additions & 7 deletions apps/dashboard/src/pages/Compute/PoolDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function PoolDetailContent() {
const location = useLocation();
const { hasPermission } = useAuth();
const canDelete = hasPermission("deployment:delete");
const canShell = hasPermission("node:shell");

// A pool is created and then linked straight to /pools/:id/workers, so the
// tab has to survive a deep link rather than always opening on Overview.
Expand Down Expand Up @@ -374,6 +375,7 @@ function PoolDetailContent() {
nodes={nodes}
poolId={id ?? ""}
canDelete={canDelete}
canShell={canShell}
onRefetch={fetchNodes}
/>
</div>
Expand Down Expand Up @@ -652,11 +654,13 @@ function NodeList({
nodes,
poolId,
canDelete,
canShell,
onRefetch,
}: {
nodes: NodeView[];
poolId: string;
canDelete: boolean;
canShell: boolean;
onRefetch: () => void | Promise<void>;
}) {
const [deletingId, setDeletingId] = useState<string | null>(null);
Expand Down Expand Up @@ -763,13 +767,15 @@ function NodeList({
>
<Activity className="w-3.5 h-3.5" /> Status
</Link>
<Link
to={`${detailBase}/shell`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Terminal className="w-3.5 h-3.5" /> Shell
</Link>
{canShell && (
<Link
to={`${detailBase}/shell`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Terminal className="w-3.5 h-3.5" /> Shell
</Link>
)}
<Link
to={`${detailBase}/logs`}
onClick={(e) => e.stopPropagation()}
Expand Down
6 changes: 3 additions & 3 deletions src/api_gateway/gateway/proxy_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,11 +574,11 @@ async def proxy_admin_workers_logs(websocket: WebSocket, node_id: str):
async def proxy_admin_workers_shell(websocket: WebSocket, node_id: str):
"""Proxy interactive shell WS to the dashboard.

Same auth pattern as /logs but requires DEPLOYMENT_UPDATE — a shell
can mutate the running container, so we gate it more tightly.
Same auth pattern as /logs but requires NODE_SHELL: a root shell on the
worker, which the default member role does not carry.
"""
await _proxy_admin_workers_ws(websocket, node_id, subpath="shell",
permission=PermissionEnum.DEPLOYMENT_UPDATE)
permission=PermissionEnum.NODE_SHELL)


async def _proxy_admin_workers_ws(websocket: WebSocket, node_id: str, *, subpath: str, permission):
Expand Down
31 changes: 17 additions & 14 deletions src/api_gateway/rbac/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@

logger = logging.getLogger(__name__)

# Default dashboard operator permissions. Admins get every permission.
MEMBER_PERMISSIONS = [
PermissionEnum.DEPLOYMENT_LIST.value,
PermissionEnum.DEPLOYMENT_CREATE.value,
PermissionEnum.DEPLOYMENT_UPDATE.value,
PermissionEnum.DEPLOYMENT_DELETE.value,
PermissionEnum.MODEL_ACCESS.value,
PermissionEnum.API_KEY_LIST.value,
PermissionEnum.API_KEY_CREATE.value,
PermissionEnum.API_KEY_REVOKE.value,
PermissionEnum.ORG_VIEW.value,
PermissionEnum.MODEL_LIST.value,
PermissionEnum.MODEL_ADD.value,
PermissionEnum.MODEL_DELETE.value,
]

async def initialize_default_org(db: AsyncSession):
"""
Initialize the default organization, roles, permissions, and superadmin if they don't exist.
Expand All @@ -36,20 +52,7 @@ async def initialize_default_org(db: AsyncSession):
db.add(admin_role)

# Member Role: Default dashboard operator permissions
member_permissions = [
PermissionEnum.DEPLOYMENT_LIST.value,
PermissionEnum.DEPLOYMENT_CREATE.value,
PermissionEnum.DEPLOYMENT_UPDATE.value,
PermissionEnum.DEPLOYMENT_DELETE.value,
PermissionEnum.MODEL_ACCESS.value,
PermissionEnum.API_KEY_LIST.value,
PermissionEnum.API_KEY_CREATE.value,
PermissionEnum.API_KEY_REVOKE.value,
PermissionEnum.ORG_VIEW.value,
PermissionEnum.MODEL_LIST.value,
PermissionEnum.MODEL_ADD.value,
PermissionEnum.MODEL_DELETE.value,
]
member_permissions = list(MEMBER_PERMISSIONS)

member_role_stmt = select(Role).where(Role.name == "member")
member_role_res = await db.execute(member_role_stmt)
Expand Down
8 changes: 2 additions & 6 deletions src/api_gateway/rbac/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,8 @@ async def auth_middleware(request: Request, call_next):
- oidc: try local (superadmin recovery), fall back to _resolve_oidc_token
- local: _resolve_local_token only
"""
# Skip auth for WebSocket connections - they handle auth differently (via query params)
# Check both 'upgrade' header and the connection type
upgrade_header = request.headers.get("upgrade", "").lower()
connection_header = request.headers.get("connection", "").lower()
if upgrade_header == "websocket" or "upgrade" in connection_header:
return await call_next(request)
# No Upgrade/Connection exemption: an HTTP middleware never sees a real
# handshake, so only a hand-set header could reach one.

# Path relative to the ASGI root_path. Under the unified app the gateway is
# mounted at /api, so Starlette sets scope['root_path']='/api' but does NOT
Expand Down
6 changes: 5 additions & 1 deletion src/api_gateway/rbac/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ def canonical_permissions() -> List[str]:
PermissionEnum.ROLE_DELETE.value,
),
"inferiallm:org:read": (PermissionEnum.ORG_VIEW.value,),
"inferiallm:org:write": (PermissionEnum.ORG_UPDATE.value,),
# node:shell sits here because only the admin role holds org:write.
"inferiallm:org:write": (
PermissionEnum.ORG_UPDATE.value,
PermissionEnum.NODE_SHELL.value,
),
"inferiallm:audit:read": (PermissionEnum.AUDIT_LOG_LIST.value,),
}

Expand Down
3 changes: 3 additions & 0 deletions src/api_gateway/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ class PermissionEnum(str, Enum):
ORG_VIEW = "organization:view"
ORG_UPDATE = "organization:update"

# Nodes. A root shell on a worker, so not in the default member role.
NODE_SHELL = "node:shell"


class OrganizationBasicInfo(BaseModel):
id: str
Expand Down
8 changes: 2 additions & 6 deletions src/common/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,8 @@ async def internal_auth_middleware(request: Request, call_next):
response.headers["X-Request-ID"] = request_id
return response

# WebSocket handshake check - browsers can't set custom headers for WS
upgrade_header = request.headers.get("upgrade", "").lower()
if upgrade_header == "websocket":
logger.info(f"Allowing WebSocket handshake for {path}")
response = await call_next(request)
return response
# No Upgrade-header exemption: an HTTP middleware never sees a real
# handshake, so only a hand-set header could reach one.

# 3. Only check if path prefix matches (if provided)
if check_path_prefix and not path.startswith(check_path_prefix):
Expand Down
28 changes: 28 additions & 0 deletions src/orchestration/api/admin_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,28 @@ async def _run_until_either(*coros) -> None:
logger.warning("_run_until_either crashed: %s", e)


async def _node_in_caller_org(ws: WebSocket, node_id: str) -> bool:
"""Whether the node sits in a pool owned by the caller's organisation.

The gateway checks the user's permission, not which nodes it covers, so a
node id from another organisation must be refused here.
"""
org_id = ws.headers.get("x-organization-id") or ""
if not org_id or _deps.inventory_repo is None or _deps.pool_repo is None:
return False
try:
node = await _deps.inventory_repo.get_node_by_id(node_id)
pool = await _deps.pool_repo.get(node["pool_id"]) if node else None
except Exception as e:
logger.warning("org check for node %s failed: %s", node_id, e)
return False
return (
pool is not None
and pool["owner_type"] == "organization"
and str(pool["owner_id"]) == org_id
)


@router.websocket("/{node_id}/logs")
async def proxy_worker_logs(ws: WebSocket, node_id: str):
"""Multiplex a live container logs stream over the worker's control WS.
Expand All @@ -456,6 +478,9 @@ async def proxy_worker_logs(ws: WebSocket, node_id: str):
bodies into the dashboard wire format, and tears the stream down on
either side disconnect.
"""
if not await _node_in_caller_org(ws, node_id):
await ws.close(code=1008)
return
await ws.accept()

stream_id = str(uuid.uuid4())
Expand Down Expand Up @@ -536,6 +561,9 @@ async def proxy_worker_shell(ws: WebSocket, node_id: str):
and the worker's ShellOutput / ShellExit / ShellError frames back into
the dashboard wire format.
"""
if not await _node_in_caller_org(ws, node_id):
await ws.close(code=1008)
return
await ws.accept()

# DePIN nodes (Nosana/Akash) run the model in a confidential, externally
Expand Down
Loading
Loading