yuzu

Management Groups

Management groups organize devices into a hierarchy for scoped access control and targeted operations. They support both static membership (manually assigned) and dynamic membership (a group that stores a scope expression; Yuzu fills only some dynamic groups from it, see the concepts table below).

Concepts

ConceptDescription
Management groupA named container for devices, with optional parent/child relationships.
Root groupThe built-in “All Devices” group (ID 000000000000) is auto-created on server startup. It cannot be deleted or re-parented. Every newly enrolled agent is automatically added to this group.
Static membershipAgents are explicitly added to or removed from the group by an operator.
Dynamic membershipA scope expression (e.g., ostype == "Windows" AND tag:department == "Finance") is stored with the group. Yuzu computes members from it only for the auto-created Service: {name} groups, and only once: the members are the agents carrying that service tag at the moment the group is created, and later tag writes do not add or remove members; the root group is filled at enrollment. For any other dynamic group created through the API the expression is stored but members are not computed, so add them with POST /api/v1/management-groups/{id}/members.
HierarchyGroups form a tree with a maximum depth of 5 levels below the root (root + 5 child levels). Role assignments on a parent group cascade to all children.
Service groupsWhen a service tag is set on an agent and no group of that name exists, Yuzu auto-creates a Service: {name} management group containing the agents that carry that tag at that moment. Membership is a snapshot taken at creation and is not refreshed afterwards.
Visibility filteringNon-admin users only see agents that belong to management groups where they hold a role assignment. Global administrators see all agents regardless of group membership.

Root Group

The root “All Devices” group is special:

Hierarchy Example

All Devices (root, auto-created)
  |-- London Office
  |     |-- London Desktops
  |     |-- London Servers
  |-- New York Office
  |     |-- New York Desktops
  |-- Service: SAP
  |-- Service: Exchange

A role assignment on “London Office” applies to both “London Desktops” and “London Servers”. This avoids duplicating role assignments at every level.

Visibility Filtering

When RBAC is enabled, the agent list shown in the dashboard and returned by the agent API is filtered based on management group role assignments:

For example, if jane.ops holds the Operator role on the “London Office” group, she sees only agents that are members of “London Office” or its child groups (“London Desktops”, “London Servers”).

When RBAC is disabled (the default posture): management-group role assignments are not consulted. Every authenticated user sees the full enrolled agent set, matching the legacy-admin “full access” behaviour. The dashboard agent list, the /api/agents endpoint, and the Echo fleet scan all draw from this path. If you later enable RBAC, visibility immediately becomes role-scoped — ensure users have appropriate management-group role assignments before enabling RBAC in production (see “Enabling RBAC” in rbac.md), or operators without a role assignment will see no agents.

REST API

All endpoints require authentication (session cookie or API token). Management group operations require appropriate RBAC permissions on the ManagementGroup securable type.

List All Groups

curl -s -b cookies.txt http://localhost:8080/api/v1/management-groups

All list endpoints return a paginated envelope:

{
  "data": [
    {
      "id": "mg_all_devices",
      "name": "All Devices",
      "description": "Root group containing all enrolled agents",
      "parent_id": "",
      "membership_type": "dynamic",
      "scope_expression": "*",
      "created_by": "system",
      "created_at": 1742360400,
      "updated_at": 1742360400
    },
    {
      "id": "mg_london",
      "name": "London Office",
      "description": "Agents in the London office",
      "parent_id": "mg_all_devices",
      "membership_type": "static",
      "scope_expression": "",
      "created_by": "admin",
      "created_at": 1742360400,
      "updated_at": 1742360400
    }
  ],
  "pagination": { "total": 2, "start": 0, "page_size": 50 },
  "meta": { "api_version": "v1" }
}

Create a Group

Static Membership

curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/management-groups \
  -H "Origin: http://localhost:8080" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "London Servers",
    "description": "Production servers in the London data center",
    "parent_id": "mg_london",
    "membership_type": "static"
  }'

The created_by field is set automatically from the authenticated session.

Returns 201 Created:

{
  "data": { "id": "a1b2c3d4e5f6" },
  "meta": { "api_version": "v1" }
}

Dynamic Membership

curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/management-groups \
  -H "Origin: http://localhost:8080" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "All Windows Endpoints",
    "description": "Windows endpoints (members added by hand)",
    "parent_id": "mg_all_devices",
    "membership_type": "dynamic",
    "scope_expression": "ostype == \"Windows\""
  }'

The scope_expression is stored with the group and written back by reads. Yuzu does not evaluate it to fill a group created this way: the new group has no members until you add them with POST /api/v1/management-groups/{id}/members. The expression is written in scope engine syntax, shown here for reference. Examples of that syntax:

ExpressionMatches
ostype == "Windows"All Windows agents
ostype == "Linux" AND tag:environment == "production"Linux agents tagged as production
tag:department == "Finance" OR tag:department == "Legal"Agents in Finance or Legal
NOT EXISTS tag:decommissionedAgents without a decommissioned tag

Get Group Details

Returns the group metadata plus its current member list.

curl -s -b cookies.txt \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6
{
  "data": {
    "id": "a1b2c3d4e5f6",
    "name": "London Servers",
    "description": "Production servers in the London data center",
    "parent_id": "mg_london",
    "membership_type": "static",
    "scope_expression": "",
    "created_by": "admin",
    "created_at": 1742360400,
    "updated_at": 1742360400,
    "members": [
      {
        "agent_id": "agent_lon_srv_01",
        "source": "static",
        "added_at": 1742360700
      },
      {
        "agent_id": "agent_lon_srv_02",
        "source": "static",
        "added_at": 1742360700
      }
    ]
  },
  "meta": { "api_version": "v1" }
}

Note: The members array contains agent_id, source (either "static" or "dynamic"), and added_at (Unix epoch seconds). It does not include hostname or OS — use the agent details endpoint for that information.

Update a Group

Use PUT to rename a group, change its description, re-parent it, or switch between static and dynamic membership. Only the fields you include in the request body are changed; omitted fields keep their current values.

Rename a Group

curl -s -b cookies.txt -X PUT \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6 \
  -H "Content-Type: application/json" \
  -d '{
    "name": "London Production Servers",
    "description": "Renamed from London Servers"
  }'

Re-parent a Group

Move a group under a different parent:

curl -s -b cookies.txt -X PUT \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6 \
  -H "Content-Type: application/json" \
  -d '{
    "parent_id": "mg_new_york"
  }'

Cycle detection: If the new parent is a descendant of the group being updated, the server returns 400 Bad Request with "re-parenting would create a cycle". For example, you cannot move “London Office” under “London Desktops” because “London Desktops” is already a child of “London Office”.

Root group restriction: The root “All Devices” group cannot be re-parented. Attempting to set parent_id on the root group returns 400 Bad Request.

Depth limit: Re-parenting must not push the group beyond the maximum hierarchy depth of 5 levels below root. Returns 400 if exceeded.

Convert to Dynamic Membership

curl -s -b cookies.txt -X PUT \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6 \
  -H "Content-Type: application/json" \
  -d '{
    "membership_type": "dynamic",
    "scope_expression": "ostype == \"Linux\" AND tag:environment == \"production\""
  }'

Returns 200 OK:

{
  "data": { "updated": true },
  "meta": { "api_version": "v1" }
}

Switching to dynamic stores the expression but does not compute members. The existing members stay as they are and nothing adds or removes members from the expression afterwards.

Delete a Group

curl -s -b cookies.txt -X DELETE \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6

Returns 200 OK:

{
  "data": { "deleted": true },
  "meta": { "api_version": "v1" }
}

The root “All Devices” group (ID 000000000000) cannot be deleted. Attempting to do so returns 403 Forbidden:

{
  "error": "cannot delete root group",
  "meta": { "api_version": "v1" }
}

Warning: Deleting a group cascade-deletes all child groups, their membership records, and their role assignments (enforced by foreign key constraints). Member agents themselves are not deleted — only their group associations are removed. Reassign child groups to a new parent before deleting if you want to preserve them.

Add a Member

curl -s -b cookies.txt -X POST \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6/members \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_lon_srv_03"
  }'

Returns 201 Created:

{
  "data": { "added": true },
  "meta": { "api_version": "v1" }
}

This endpoint is intended for static membership groups. For a dynamic group the server computes members only for the root group (filled at enrollment) and, as a one-time snapshot at creation, for the Service: {name} groups it creates itself when a service tag is first written; for any other dynamic group created through the API, members are not computed from the scope expression.

Remove a Member

curl -s -b cookies.txt -X DELETE \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/a1b2c3d4e5f6/members/agent_lon_srv_03

Returns 200 OK:

{
  "data": { "removed": true },
  "meta": { "api_version": "v1" }
}

Only removes static membership records. Dynamic membership entries (the root group’s enrollment members and the one-time snapshot of a Service: {name} group) are not removed by this endpoint.

Role Delegation

Management groups serve as the scoping boundary for RBAC. You can assign roles to principals within the context of a specific group, so the permissions only apply to devices in that group (and its children).

List Role Assignments for a Group

curl -s -b cookies.txt \
  http://localhost:8080/api/v1/management-groups/mg_london/roles
{
  "data": [
    {
      "group_id": "mg_london",
      "principal_type": "user",
      "principal_id": "jane.doe",
      "role_name": "Operator"
    },
    {
      "group_id": "mg_london",
      "principal_type": "group",
      "principal_id": "london-desktop-team",
      "role_name": "Viewer"
    }
  ],
  "meta": { "api_version": "v1" }
}

Add a Role Assignment

Only Operator and Viewer roles can be delegated via this endpoint. Assigning other roles (e.g., ITServiceOwner) returns 403 Forbidden. The caller must be a global Administrator or hold ITServiceOwner on the target group.

curl -s -b cookies.txt -X POST \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/mg_london/roles \
  -H "Content-Type: application/json" \
  -d '{
    "principal_type": "user",
    "principal_id": "jane.doe",
    "role_name": "Operator"
  }'

Returns 201 Created:

{
  "data": { "assigned": true },
  "meta": { "api_version": "v1" }
}

Jane now has Operator permissions for all devices in “London Office” and its child groups (“London Desktops”, “London Servers”).

Remove a Role Assignment

curl -s -b cookies.txt -X DELETE \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/mg_london/roles \
  -H "Content-Type: application/json" \
  -d '{
    "principal_type": "user",
    "principal_id": "jane.doe",
    "role_name": "Operator"
  }'

Returns 200 OK:

{
  "data": { "unassigned": true },
  "meta": { "api_version": "v1" }
}

The caller must be a global Administrator or hold ITServiceOwner on the target group.

Service Groups

When an agent’s service tag is set (e.g., via the asset tagging API), Yuzu auto-creates a management group named Service: {value} if none exists. The group starts with the agents that carry that service tag at the moment it is created. Membership is a snapshot: a group that already exists is left unchanged, so tagging or untagging an agent later does not add it to or remove it from the group. To add an agent to the group, use POST /api/v1/management-groups/{id}/members.

# Tag an agent with a service
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/tags \
  -H "Origin: http://localhost:8080" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_lon_srv_01",
    "key": "service",
    "value": "SAP"
  }'

If the group Service: SAP does not exist yet, this creates it with the agents tagged service = "SAP" (here agent_lon_srv_01) as members. If it already exists, the call leaves its membership unchanged. The group is stored as dynamic with scope expression tag:service == "SAP", but Yuzu does not re-evaluate that expression after the group is created.

Practical Example: Regional Delegation

Set up a structure where each regional team manages their own devices:

# 1. Create regional groups (created_by is set from the authenticated session)
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/management-groups \
  -H "Origin: http://localhost:8080" \
  -H "Content-Type: application/json" \
  -d '{"name": "EMEA", "description": "Europe, Middle East, Africa", "membership_type": "static"}'

curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/management-groups \
  -H "Origin: http://localhost:8080" \
  -H "Content-Type: application/json" \
  -d '{"name": "APAC", "description": "Asia-Pacific", "membership_type": "static"}'

# 2. Add agents to EMEA (use the id returned by the create call)
curl -s -b cookies.txt -X POST \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/<emea_group_id>/members \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "agent_london_01"}'

# 3. Delegate Operator to the EMEA team lead
curl -s -b cookies.txt -X POST \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/<emea_group_id>/roles \
  -H "Content-Type: application/json" \
  -d '{"principal_type": "user", "principal_id": "emea.lead", "role_name": "Operator"}'

# 4. Delegate Viewer to the EMEA helpdesk group
curl -s -b cookies.txt -X POST \
  -H "Origin: http://localhost:8080" \
  http://localhost:8080/api/v1/management-groups/<emea_group_id>/roles \
  -H "Content-Type: application/json" \
  -d '{"principal_type": "group", "principal_id": "emea-helpdesk", "role_name": "Viewer"}'

Now emea.lead can manage all devices in EMEA, and members of the emea-helpdesk group can view those devices — but neither has access to APAC devices.

Dashboard Management

The Groups and enrolment page (/groups, Estate) shows the management groups read-only: the tree with each group’s type and member count, the selected group’s details (parent, description, who created it and when, and the stored rule of a rule-based group, that is, one of type dynamic), and its member list. A group’s members are shown only within the viewer’s scope, and the panel says so without stating how many were left out. A panel that cannot be read says so with a Retry button rather than showing an empty tree or “0 members”. Membership of a rule-based group is not kept up to date from its rule, so such a group may show no members; the Dynamic membership row under Concepts describes how each kind is filled.

Groups are created and deleted through the Settings page in the HTMX dashboard. The Settings page displays all groups in a tree view with indentation reflecting the hierarchy. The root “All Devices” group is always displayed first.

From the Settings page, administrators can:

Group updates (rename, re-parent, membership type changes) are performed via the REST API PUT endpoint.

Prometheus Metrics

Two gauges are exposed on the /metrics endpoint, refreshed on every scrape:

MetricTypeDescription
yuzu_server_management_groups_totalgaugeTotal number of management groups
yuzu_server_group_members_totalgaugeTotal membership records across all groups

API Endpoint Summary

MethodEndpointDescription
GET/api/v1/management-groupsList all management groups
POST/api/v1/management-groupsCreate a management group
GET/api/v1/management-groups/{id}Get group details and members
PUT/api/v1/management-groups/{id}Update a group (rename, re-parent, change membership type)
DELETE/api/v1/management-groups/{id}Delete a management group (root group returns 403)
POST/api/v1/management-groups/{id}/membersAdd an agent to a static group
DELETE/api/v1/management-groups/{id}/members/{agent_id}Remove an agent from a group
GET/api/v1/management-groups/{id}/rolesList role assignments for a group
POST/api/v1/management-groups/{id}/rolesAdd a role assignment to a group
DELETE/api/v1/management-groups/{id}/rolesRemove a role assignment from a group

Constraints

ConstraintValue
Root group ID000000000000 — auto-created on startup, cannot be deleted or re-parented
Maximum hierarchy depth5 levels below root (root + 5 child levels)
Cycle detectionRe-parenting a group under one of its own descendants is rejected
Membership typesstatic, dynamic
Delegatable rolesOnly Operator and Viewer can be delegated via the roles API
Cascade deleteDeleting a group cascade-deletes all children, memberships, and role assignments
Dynamic scope syntaxSame as the scope engine (see docs/yaml-dsl-spec.md section on scope expressions)
Visibility filteringNon-admin users see only agents in groups where they hold a role assignment

Planned Features

FeaturePhaseStatus
Policy assignment to management groups5Stub
Dynamic membership auto-refresh on schedule3Stub