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
4 changes: 4 additions & 0 deletions .github/workflows/build-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ jobs:
contents: write
env:
GH_TOKEN: ${{ github.token }}
# This job deliberately skips actions/checkout, so the gh CLI has no git
# remote to infer the repository from. Name it explicitly or every
# `gh release` call fails with "not a git repository".
GH_REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
BOR_RELEASE_GPG_KEY: ${{ secrets.BOR_RELEASE_GPG_KEY }}
SIGN_KEY_PASSPHRASE: ${{ secrets.BOR_RELEASE_GPG_PASSPHRASE }}
Expand Down
2 changes: 1 addition & 1 deletion doc/installation/upgrade-uninstall.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ are just no longer protected against changes). Let the agent remove its
policies first, then uninstall it.

1. **Withdraw all policies from the node.** On **Fleet -> Nodes**, select
the node and use **Remove from group** to remove it from every node group.
the node and use **Change groups** to clear every node group.
2. **Let the agent apply the empty policy set.** On the desktop, restart the
agent so it reconnects with its new (empty) group membership:

Expand Down
63 changes: 55 additions & 8 deletions doc/user-guide/node-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
A node group is a named set of nodes. Groups are how policies reach
machines: you bind a policy to a node group, and every node in the group
receives it. This page covers creating, editing and deleting node groups,
how membership works, and generating enrollment tokens.
how membership works, adding and removing member nodes, and generating
enrollment tokens.

Open it from **Fleet -> Node Groups** in the sidebar.

Expand All @@ -20,8 +21,9 @@ Open it from **Fleet -> Node Groups** in the sidebar.
policy types; see [Bindings](bindings.md#priority-and-conflicts).

New nodes join the node group of the enrollment token they used. You can
add them to more groups later on the [Nodes](nodes.md#move-nodes-between-groups)
page.
add them to more groups later, either from the group (see
[Manage the members of a group](#manage-the-members-of-a-group)) or from
the node on the [Nodes](nodes.md#move-nodes-between-groups) page.

## The node group list

Expand All @@ -31,12 +33,13 @@ page.
|---|---|
| **Name** | Group name. Sortable. |
| **Description** | Optional description. |
| **Nodes** | Number of member nodes. Sortable. |
| **Nodes** | Number of member nodes. Select it to open the [Nodes](nodes.md) page filtered to this group. Sortable. |
| **Created** | Creation time. Sortable. |

Use **Search by name or description...** to filter the list. The row menu
offers **Generate token**, **Edit** and **Delete**. Tick several rows to use
**Actions** for bulk **Edit** (one row only) and **Delete**.
offers **Add nodes...**, **Manage members**, **Generate token**, **Edit**
and **Delete**. Tick several rows to use **Actions** for bulk **Edit** (one
row only) and **Delete**.

## Create a node group

Expand All @@ -54,6 +57,50 @@ Requires `node_group:create`.
baseline policies. Because nodes can be in several groups, you can
combine a baseline group with more specific ones.

## Manage the members of a group

### Add nodes

1. In the row menu of the group, select **Add nodes...**.
2. The dialog lists the nodes that are not members yet, with their status
and current groups. Use the search box to narrow the list (name, FQDN
or IP address); ticked nodes stay selected when you search again. At
most 100 nodes are listed at a time.
3. Tick the nodes and select **Add**.

The nodes keep their other groups. Requires `node:view` and `node:create`.

### Review and remove members

1. In the row menu of the group, select **Manage members**.
2. The dialog lists the member nodes with their status and their other
groups. Search and page through the list as needed.
3. To remove one node, select **Remove** in its row and type the node name
to confirm. To remove several, tick them, select **Remove selected**
and type `Yes`.

A removed node stops receiving the policies bound to this group and keeps
its other memberships; a node left in no group receives no policies.
**Open in Nodes** opens the [Nodes](nodes.md) page filtered to the group.
Viewing requires `node:view`; removing requires `node:delete`.

To set all groups of one node in a single dialog, use **Change groups** on
the [Nodes](nodes.md#move-nodes-between-groups) page.

### From a script

The same actions are available over the REST API:

```text
POST /api/v1/node-groups/{group_id}/nodes {"node_ids": ["...", "..."]}
DELETE /api/v1/node-groups/{group_id}/nodes/{node_id}
```

The `POST` adds up to 500 nodes in one call, applies fully or not at all,
skips nodes that already are members and answers `{"added": N}`. Both
calls need the same permissions as the dialogs and are recorded in the
[audit log](audit-log.md).

## Edit a node group

Select **Edit** in the row menu. You can change the **Name** and
Expand All @@ -66,8 +113,8 @@ auto-enrollment (see [Kerberos](../configuration/kerberos.md)). Requires

1. Make sure the group has no member nodes. The server refuses to delete a
group that still has nodes assigned (`cannot delete node group: N
node(s) still assigned`). Remove the nodes on the
[Nodes](nodes.md#move-nodes-between-groups) page first, or decommission
node(s) still assigned`). Remove them with
[**Manage members**](#review-and-remove-members) first, or decommission
them.
2. Select **Delete** in the row menu (or **Actions -> Delete** for several
groups).
Expand Down
32 changes: 20 additions & 12 deletions doc/user-guide/nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Select a row to open the details panel on the right.
: - **Update metadata** - asks the agent to collect fresh system
information (OS, desktop, IP address and so on) and report it. Only
available while the node is `online`.
- **Add to group** / **Remove from group** - see
- **Change groups** - see
[Move nodes between groups](#move-nodes-between-groups).
- **Decommission** - see [Decommission a node](#decommission-a-node).
- **Revoke Certificate** - see [Revoke a certificate](#revoke-a-certificate).
Expand All @@ -144,20 +144,28 @@ ships the agent package repository. See
A node can belong to several node groups at the same time; it receives the
policies bound to all of them. See [Node groups](node-groups.md).

To add nodes to a group:
One dialog sets the whole membership of a node:

1. Select one node and open its details panel, or tick several nodes and
open **Actions**.
2. Choose **Add to group**.
3. Pick the **Node group** and select **Assign**.

To remove nodes from groups:

1. Select the nodes and choose **Remove from group**.
2. Tick the groups to remove them from (when the nodes belong to only one
group it is preselected) and select **Remove**.

Adding nodes needs `node:create`; removing them needs `node:delete`.
2. Choose **Change groups**. The dialog lists every node group; the groups
the node is already in are ticked.
3. Tick the groups the node should be in, clear the ones it should leave,
and select **Save**. The line under the list sums up what will be added
and removed.

With several nodes selected, a group that only some of them are in is
shown in the mixed state, with the number of members under its name.
Tick it to put all selected nodes in the group, clear it to remove them
all, or leave it mixed to keep it as it is. Only the groups you changed
are applied.

Adding nodes needs `node:create`; removing them needs `node:delete`. A
box you may not change is disabled.

To work from the group instead (add many nodes to one group, or review
and remove its members), use **Add nodes...** and **Manage members** on
the [Node groups](node-groups.md#manage-the-members-of-a-group) page.

!!! warning "Group changes take effect on the next agent connection"
The server reads a node's group memberships when the agent opens its
Expand Down
136 changes: 136 additions & 0 deletions server/internal/api/node_group_members.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vute Tech LTD
// Copyright (C) 2026 Bor contributors

package api

import (
"encoding/json"
"log"
"net/http"

"github.com/VuteTech/Bor/server/internal/services"
)

// maxGroupMemberBatch bounds how many nodes one request may add to a group.
const maxGroupMemberBatch = 500

// NodeGroupMembersHandler changes group membership from the group side:
// add several nodes to a group in one call, or remove one member. The
// authorization rules are those of the per-node membership endpoints.
type NodeGroupMembersHandler struct {
nodes *NodeHandler
nodeGroupSvc *services.NodeGroupService
}

// NewNodeGroupMembersHandler creates a NodeGroupMembersHandler. It shares
// the node handler's services and membership-change notification.
func NewNodeGroupMembersHandler(nodes *NodeHandler, nodeGroupSvc *services.NodeGroupService) *NodeGroupMembersHandler {
return &NodeGroupMembersHandler{nodes: nodes, nodeGroupSvc: nodeGroupSvc}
}

// groupExists answers 404 itself when the group is missing.
func (h *NodeGroupMembersHandler) groupExists(w http.ResponseWriter, r *http.Request, groupID string) bool {
group, err := h.nodeGroupSvc.GetNodeGroup(r.Context(), groupID)
if err != nil || group == nil {
writeJSONError(w, http.StatusNotFound, "node group not found")
return false
}
return true
}

// Add handles POST /api/v1/node-groups/{id}/nodes with {"node_ids": [...]}.
// Every node is checked before anything is written, and the memberships are
// created in one statement, so the request applies fully or not at all.
func (h *NodeGroupMembersHandler) Add(w http.ResponseWriter, r *http.Request) {
groupID := r.PathValue("id")

var req struct {
NodeIDs []string `json:"node_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
nodeIDs := uniqueNonEmpty(req.NodeIDs)
if len(nodeIDs) == 0 {
writeJSONError(w, http.StatusBadRequest, "node_ids is required")
return
}
if len(nodeIDs) > maxGroupMemberBatch {
writeJSONError(w, http.StatusBadRequest, "too many node_ids in one request")
return
}

// The target group must be in scope: a delegated administrator can only
// move nodes between their own groups.
if !requestScope(r, "node", "create").Allows(groupID) {
denyOutOfScope(w, r, "node_group", "create", groupID)
return
}
if !h.groupExists(w, r, groupID) {
return
}
for _, nodeID := range nodeIDs {
if _, ok := h.nodes.loadWritableNode(w, r, nodeID, "create"); !ok {
return
}
}

added, err := h.nodes.nodeSvc.AddNodesToGroup(r.Context(), groupID, nodeIDs)
if err != nil {
log.Printf("Failed to add %d node(s) to group %s: %v", len(nodeIDs), groupID, err) //nolint:gosec // groupID comes from URL path parameter
writeJSONError(w, http.StatusInternalServerError, "failed to add nodes to group")
return
}
if h.nodes.OnMembershipChange != nil {
for _, nodeID := range nodeIDs {
h.nodes.OnMembershipChange(nodeID)
}
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]int{"added": added}); err != nil {
log.Printf("Failed to encode group members response: %v", err)
}
}

// Remove handles DELETE /api/v1/node-groups/{id}/nodes/{node_id}.
func (h *NodeGroupMembersHandler) Remove(w http.ResponseWriter, r *http.Request) {
groupID := r.PathValue("id")
nodeID := r.PathValue("node_id")

if _, ok := h.nodes.loadWritableNode(w, r, nodeID, "delete"); !ok {
return
}
if !requestScope(r, "node", "delete").Allows(groupID) {
denyOutOfScope(w, r, "node_group", "delete", groupID)
return
}
if err := h.nodes.nodeSvc.RemoveNodeFromGroup(r.Context(), nodeID, groupID); err != nil {
log.Printf("Failed to remove node %s from group %s: %v", nodeID, groupID, err) //nolint:gosec // ids come from URL path parameters
writeJSONError(w, http.StatusInternalServerError, "failed to remove node from group")
return
}
if h.nodes.OnMembershipChange != nil {
h.nodes.OnMembershipChange(nodeID)
}
w.WriteHeader(http.StatusNoContent)
}

// uniqueNonEmpty returns ids without blanks and duplicates, order kept.
func uniqueNonEmpty(ids []string) []string {
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
69 changes: 69 additions & 0 deletions server/internal/api/node_group_members_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vute Tech LTD
// Copyright (C) 2026 Bor contributors

package api

import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)

func TestUniqueNonEmpty(t *testing.T) {
tests := []struct {
name string
in []string
want []string
}{
{"nil", nil, []string{}},
{"blanks dropped", []string{"", "a", ""}, []string{"a"}},
{"duplicates dropped, order kept", []string{"b", "a", "b", "c", "a"}, []string{"b", "a", "c"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := uniqueNonEmpty(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Errorf("uniqueNonEmpty(%v) = %v, want %v", tt.in, got, tt.want)
}
})
}
}

// Requests that fail validation are answered before any service is touched.
func TestNodeGroupMembersHandler_Add_RejectsBadBodies(t *testing.T) {
tooMany := `{"node_ids":["` + strings.Repeat(`x","`, maxGroupMemberBatch) + `y"]}`
// Distinct ids are needed to exceed the cap after de-duplication.
ids := make([]string, 0, maxGroupMemberBatch+1)
for i := 0; i <= maxGroupMemberBatch; i++ {
ids = append(ids, `"n`+strings.Repeat("i", i)+`"`)
}
tooManyDistinct := `{"node_ids":[` + strings.Join(ids, ",") + `]}`

tests := []struct {
name string
body string
want int
}{
{"not JSON", "nope", http.StatusBadRequest},
{"missing node_ids", `{}`, http.StatusBadRequest},
{"only blanks", `{"node_ids":["",""]}`, http.StatusBadRequest},
{"over the batch cap", tooManyDistinct, http.StatusBadRequest},
// Duplicates collapse to two ids; with no grant in the request the
// call is then refused as out of scope.
{"duplicates collapse, then scope applies", tooMany, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewNodeGroupMembersHandler(&NodeHandler{}, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/node-groups/g1/nodes", strings.NewReader(tt.body))
req.SetPathValue("id", "g1")
rr := httptest.NewRecorder()
h.Add(rr, req)
if rr.Code != tt.want {
t.Errorf("Add() status = %d, want %d (body %s)", rr.Code, tt.want, rr.Body.String())
}
})
}
}
1 change: 1 addition & 0 deletions server/internal/api/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func (h *NodeHandler) List(w http.ResponseWriter, r *http.Request) {
LSM: q.Get("lsm"),
Updates: q.Get("updates"),
Group: q.Get("group"),
ExcludeGroup: q.Get("exclude_group"),
SortField: q.Get("sort_field"),
SortOrder: q.Get("sort_order"),
Page: atoiDefault(q.Get("page"), 1),
Expand Down
Loading
Loading