From 896f6d533d0d9710da44291cc6b198b30835aee3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Sharma Date: Wed, 22 Jul 2026 17:26:46 +0530 Subject: [PATCH 1/2] feat: adding Nautobot Admin Groups and Permission sync --- go/nautobotop/api/v1alpha1/nautobot_types.go | 1 + go/nautobotop/internal/controller/dag_test.go | 1 + .../controller/nautobot_controller.go | 18 ++ .../internal/nautobot/admin/group.go | 78 +++++++++ .../internal/nautobot/admin/permission.go | 155 +++++++++++++++++ .../nautobot/models/admin/permissionGroup.go | 48 ++++++ .../nautobot/sync/admin/permissionGroup.go | 161 ++++++++++++++++++ 7 files changed, 462 insertions(+) create mode 100644 go/nautobotop/internal/nautobot/admin/group.go create mode 100644 go/nautobotop/internal/nautobot/admin/permission.go create mode 100644 go/nautobotop/internal/nautobot/models/admin/permissionGroup.go create mode 100644 go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go diff --git a/go/nautobotop/api/v1alpha1/nautobot_types.go b/go/nautobotop/api/v1alpha1/nautobot_types.go index 3d7607b4e..a60727610 100644 --- a/go/nautobotop/api/v1alpha1/nautobot_types.go +++ b/go/nautobotop/api/v1alpha1/nautobot_types.go @@ -52,6 +52,7 @@ type NautobotSpec struct { TenantGroupRef []ConfigMapRef `json:"tenantGroupRef,omitempty"` TenantRef []ConfigMapRef `json:"tenantRef,omitempty"` DeviceRef []ConfigMapRef `json:"deviceRef,omitempty"` + PermissionGroupRef []ConfigMapRef `json:"permissionGroupRef,omitempty"` } // NautobotStatus defines the observed state of Nautobot. diff --git a/go/nautobotop/internal/controller/dag_test.go b/go/nautobotop/internal/controller/dag_test.go index 80c7228af..dd4dd4a23 100644 --- a/go/nautobotop/internal/controller/dag_test.go +++ b/go/nautobotop/internal/controller/dag_test.go @@ -82,6 +82,7 @@ func TestTopologicalSort(t *testing.T) { {Name: "namespace", DependsOn: []string{"location", "tenant"}}, {Name: "vlan", DependsOn: []string{"vlanGroup", "location", "tenant", "role"}}, {Name: "prefix", DependsOn: []string{"namespace", "rir", "location", "vlan", "tenant", "role"}}, + {Name: "permissionGroup"}, }, want: []string{ "clusterGroup", "clusterType", "deviceType", "locationTypes", diff --git a/go/nautobotop/internal/controller/nautobot_controller.go b/go/nautobotop/internal/controller/nautobot_controller.go index 8bd485e97..18a0dcfc3 100644 --- a/go/nautobotop/internal/controller/nautobot_controller.go +++ b/go/nautobotop/internal/controller/nautobot_controller.go @@ -28,6 +28,7 @@ import ( nbClient "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/sync" + syncadmin "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/sync/admin" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -116,6 +117,7 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c {name: "cluster", dependsOn: []string{"clusterType", "clusterGroup", "location", "device"}, configRefs: nautobotCR.Spec.ClusterRef, syncFunc: r.syncCluster}, // Depends on: namespace, rir, location, vlan, tenant, role {name: "prefix", dependsOn: []string{"namespace", "rir", "location", "vlan", "tenant", "role"}, configRefs: nautobotCR.Spec.PrefixRef, syncFunc: r.syncPrefix}, + {name: "permissionGroup", configRefs: nautobotCR.Spec.PermissionGroupRef, syncFunc: r.syncPermissionGroup}, } // Resolve execution order using topological sort (Kahn's algorithm) @@ -298,6 +300,22 @@ func (r *NautobotReconciler) syncRack(ctx context.Context, return nil } +func (r *NautobotReconciler) syncPermissionGroup(ctx context.Context, + nautobotClient *nbClient.NautobotClient, + permissionGroupData map[string]string, +) error { + log := logf.FromContext(ctx) + log.Info("syncing permission groups", "count", len(permissionGroupData)) + if len(permissionGroupData) == 0 { + return nil + } + syncSvc := syncadmin.NewPermissionGroupSync(nautobotClient) + if err := syncSvc.SyncAll(ctx, permissionGroupData); err != nil { + return fmt.Errorf("failed to sync permission groups: %w", err) + } + return nil +} + func (r *NautobotReconciler) syncLocation(ctx context.Context, nautobotClient *nbClient.NautobotClient, locationType map[string]string, diff --git a/go/nautobotop/internal/nautobot/admin/group.go b/go/nautobotop/internal/nautobot/admin/group.go new file mode 100644 index 000000000..893fb2105 --- /dev/null +++ b/go/nautobotop/internal/nautobot/admin/group.go @@ -0,0 +1,78 @@ +package admin + +import ( + "context" + "net/http" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" +) + +type GroupService struct { + client *client.NautobotClient +} + +func NewGroupService(nautobotClient *client.NautobotClient) *GroupService { + return &GroupService{ + client: nautobotClient, + } +} + +func (s *GroupService) Create(ctx context.Context, req nb.GroupRequest) (*nb.Group, error) { + group, resp, err := s.client.APIClient.UsersAPI.UsersGroupsCreate(ctx).GroupRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("createNewGroup", "failed to create", "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("CreateGroup", "created group", group.Name) + return group, nil +} + +func (s *GroupService) GetByName(ctx context.Context, name string) *nb.Group { + list, resp, err := s.client.APIClient.UsersAPI.UsersGroupsList(ctx).Name([]string{name}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetGroupByName", "failed to get", "name", name, "error", err.Error(), "response_body", bodyString) + return nil + } + if list == nil || len(list.Results) == 0 { + return nil + } + return &list.Results[0] +} + +func (s *GroupService) ListAll(ctx context.Context) []nb.Group { + return helpers.PaginatedList( + ctx, + func(ctx context.Context, limit, offset int32) ([]nb.Group, int32, *http.Response, error) { + list, resp, err := s.client.APIClient.UsersAPI.UsersGroupsList(ctx). + Limit(limit). + Offset(offset). + Execute() + if err != nil { + return nil, 0, resp, err + } + if list == nil { + return nil, 0, resp, nil + } + return list.Results, list.Count, resp, nil + }, + s.client.AddReport, + "ListAllGroups", + ) +} + +// GetOrCreate fetches a group by name or creates it if it doesn't exist. +func (s *GroupService) GetOrCreate(ctx context.Context, name string) (*nb.Group, error) { + existing := s.GetByName(ctx, name) + if existing != nil { + return existing, nil + } + + log.Info("group not found, creating", "name", name) + req := nb.GroupRequest{Name: name} + return s.Create(ctx, req) +} diff --git a/go/nautobotop/internal/nautobot/admin/permission.go b/go/nautobotop/internal/nautobot/admin/permission.go new file mode 100644 index 000000000..b1067a672 --- /dev/null +++ b/go/nautobotop/internal/nautobot/admin/permission.go @@ -0,0 +1,155 @@ +package admin + +import ( + "context" + "net/http" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" +) + +type PermissionService struct { + client *client.NautobotClient +} + +func NewPermissionService(nautobotClient *client.NautobotClient) *PermissionService { + return &PermissionService{ + client: nautobotClient, + } +} + +func (s *PermissionService) Create(ctx context.Context, req nb.ObjectPermissionRequest) (*nb.ObjectPermission, error) { + perm, resp, err := s.client.APIClient.UsersAPI.UsersPermissionsCreate(ctx).ObjectPermissionRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("createNewPermission", "failed to create", "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("CreatePermission", "created permission", perm.Name) + return perm, nil +} + +func (s *PermissionService) GetByName(ctx context.Context, name string) *nb.ObjectPermission { + list, resp, err := s.client.APIClient.UsersAPI.UsersPermissionsList(ctx).Name([]string{name}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetPermissionByName", "failed to get", "name", name, "error", err.Error(), "response_body", bodyString) + return nil + } + if list == nil || len(list.Results) == 0 { + return nil + } + return &list.Results[0] +} + +func (s *PermissionService) GetByID(ctx context.Context, id string) *nb.ObjectPermission { + if id == "" { + return nil + } + list, resp, err := s.client.APIClient.UsersAPI.UsersPermissionsList(ctx).Id([]string{id}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetPermissionByID", "failed to get", "id", id, "error", err.Error(), "response_body", bodyString) + return nil + } + if list == nil || len(list.Results) == 0 { + return nil + } + return &list.Results[0] +} + +func (s *PermissionService) ListAll(ctx context.Context) []nb.ObjectPermission { + return helpers.PaginatedList( + ctx, + func(ctx context.Context, limit, offset int32) ([]nb.ObjectPermission, int32, *http.Response, error) { + list, resp, err := s.client.APIClient.UsersAPI.UsersPermissionsList(ctx). + Limit(limit). + Offset(offset). + Execute() + if err != nil { + return nil, 0, resp, err + } + if list == nil { + return nil, 0, resp, nil + } + return list.Results, list.Count, resp, nil + }, + s.client.AddReport, + "ListAllPermissions", + ) +} + +// PermissionUpdatePayload represents the desired state of a permission. +type PermissionUpdatePayload struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Actions interface{} `json:"actions"` + ObjectTypes []string `json:"object_types"` + Constraints interface{} `json:"constraints"` + Groups []GroupRef `json:"groups"` +} + +// GroupRef is a minimal group reference for the permission update payload. +type GroupRef struct { + ID int32 `json:"id"` +} + +// Update performs a full PUT via the SDK client +func (s *PermissionService) Update(ctx context.Context, id string, payload PermissionUpdatePayload) error { + // Build the SDK request + req := *nb.NewObjectPermissionRequest(payload.ObjectTypes, payload.Name, payload.Actions) + req.Enabled = nb.PtrBool(payload.Enabled) + if payload.Description != "" { + req.Description = nb.PtrString(payload.Description) + } + + // Set groups + groups := make([]nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, 0, len(payload.Groups)) + for _, g := range payload.Groups { + gid := g.ID + groups = append(groups, nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{ + Id: &nb.ApprovalWorkflowApprovalWorkflowDefinitionId{Int32: &gid}, + }) + } + req.Groups = groups + + if payload.Constraints != nil { + req.Constraints = payload.Constraints + } else { + req.AdditionalProperties = map[string]interface{}{ + "constraints": nil, + } + } + + perm, resp, err := s.client.APIClient.UsersAPI.UsersPermissionsUpdate(ctx, id).ObjectPermissionRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("UpdatePermission", "failed to update", "id", id, "model", payload.Name, "error", err.Error(), "response_body", bodyString) + return err + } + log.Info("successfully updated permission", "id", id, "model", perm.GetName()) + return nil +} + +func (s *PermissionService) Destroy(ctx context.Context, id string) error { + owned, err := s.client.IsCreatedByUser(ctx, id) + if err != nil { + s.client.AddReport("DestroyPermission", "failed to check ownership", "id", id, "error", err.Error()) + return err + } + if !owned { + log.Warn("skipping destroy, object not created by user", "id", id, "user", s.client.Username) + return nil + } + + resp, err := s.client.APIClient.UsersAPI.UsersPermissionsDestroy(ctx, id).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("DestroyPermission", "failed to destroy", "id", id, "error", err.Error(), "response_body", bodyString) + return err + } + return nil +} diff --git a/go/nautobotop/internal/nautobot/models/admin/permissionGroup.go b/go/nautobotop/internal/nautobot/models/admin/permissionGroup.go new file mode 100644 index 000000000..5a65be552 --- /dev/null +++ b/go/nautobotop/internal/nautobot/models/admin/permissionGroup.go @@ -0,0 +1,48 @@ +package admin + +// Permission represents a single Nautobot ObjectPermission with its group assignments. +type Permission struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Enabled *bool `json:"enabled" yaml:"enabled"` + CanView bool `json:"can_view" yaml:"can_view"` + CanAdd bool `json:"can_add" yaml:"can_add"` + CanChange bool `json:"can_change" yaml:"can_change"` + CanDelete bool `json:"can_delete" yaml:"can_delete"` + AdditionalActions []string `json:"additional_actions" yaml:"additional_actions"` + ObjectTypes []string `json:"object_types" yaml:"object_types"` + Groups []string `json:"groups" yaml:"groups"` + Constraints string `json:"constraints" yaml:"constraints"` +} + +// PermissionGroupConfig represents the top-level structure for the permissions configmap. +type PermissionGroupConfig struct { + Permissions []Permission `json:"permissions" yaml:"permissions"` +} + +// BuildActions constructs the actions list from the boolean flags and additional actions. +func (p *Permission) BuildActions() []string { + var actions []string + if p.CanView { + actions = append(actions, "view") + } + if p.CanAdd { + actions = append(actions, "add") + } + if p.CanChange { + actions = append(actions, "change") + } + if p.CanDelete { + actions = append(actions, "delete") + } + actions = append(actions, p.AdditionalActions...) + return actions +} + +// IsEnabled returns whether the permission is enabled (defaults to true if not set). +func (p *Permission) IsEnabled() bool { + if p.Enabled == nil { + return true + } + return *p.Enabled +} diff --git a/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go b/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go new file mode 100644 index 000000000..7ebca287f --- /dev/null +++ b/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go @@ -0,0 +1,161 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + adminsvc "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/admin" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + adminmodels "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/models/admin" + "go.yaml.in/yaml/v3" +) + +type PermissionGroupSync struct { + client *client.NautobotClient + groupSvc *adminsvc.GroupService + permissionSvc *adminsvc.PermissionService +} + +func NewPermissionGroupSync(nautobotClient *client.NautobotClient) *PermissionGroupSync { + return &PermissionGroupSync{ + client: nautobotClient.GetClient(), + groupSvc: adminsvc.NewGroupService(nautobotClient), + permissionSvc: adminsvc.NewPermissionService(nautobotClient), + } +} + +func (s *PermissionGroupSync) SyncAll(ctx context.Context, data map[string]string) error { + var allPermissions []adminmodels.Permission + + for key, f := range data { + var config adminmodels.PermissionGroupConfig + if err := yaml.Unmarshal([]byte(f), &config); err != nil { + s.client.AddReport("yamlFailed", "file: "+key+" error: "+err.Error()) + return err + } + allPermissions = append(allPermissions, config.Permissions...) + } + + if len(allPermissions) == 0 { + log.Info("no permissions to sync") + return nil + } + + groupNameToID := make(map[string]int32) + + for _, perm := range allPermissions { + if err := s.syncSinglePermission(ctx, perm, groupNameToID); err != nil { + return err + } + } + + return nil +} + +func (s *PermissionGroupSync) syncSinglePermission( + ctx context.Context, + perm adminmodels.Permission, + groupNameToID map[string]int32, +) error { + if perm.Name == "" { + s.client.AddReport("permissionInvalid", "permission has no name, skipping") + return nil + } + + actions := perm.BuildActions() + if len(actions) == 0 { + s.client.AddReport("permissionInvalid", "permission has no actions, skipping", "name", perm.Name) + log.Warn("permission has no actions, skipping", "name", perm.Name) + return nil + } + + var groupIDs []int32 + for _, groupName := range perm.Groups { + gid, err := s.resolveGroupID(ctx, groupName, groupNameToID) + if err != nil { + return fmt.Errorf("failed to resolve group %q for permission %q: %w", groupName, perm.Name, err) + } + groupIDs = append(groupIDs, gid) + } + + // Parse constraints (nil if empty) + var constraints interface{} + if perm.Constraints != "" { + if err := json.Unmarshal([]byte(perm.Constraints), &constraints); err != nil { + s.client.AddReport("constraintsInvalid", "invalid JSON constraints", "name", perm.Name, "error", err.Error()) + log.Warn("invalid JSON constraints, skipping constraints", "name", perm.Name, "error", err) + } + } + + // Check if the permission already exists + existing := s.permissionSvc.GetByName(ctx, perm.Name) + if existing != nil && existing.Id != nil { + payload := adminsvc.PermissionUpdatePayload{ + Name: perm.Name, + Description: perm.Description, + Enabled: perm.IsEnabled(), + Actions: actions, + ObjectTypes: perm.ObjectTypes, + Constraints: constraints, // nil becomes JSON null, clearing it + } + for _, gid := range groupIDs { + payload.Groups = append(payload.Groups, adminsvc.GroupRef{ID: gid}) + } + + if err := s.permissionSvc.Update(ctx, *existing.Id, payload); err != nil { + return fmt.Errorf("failed to update permission %q: %w", perm.Name, err) + } + log.Info("permission synced (updated)", "name", perm.Name, "groups", len(groupIDs)) + } else { + // Create via SDK + groupRefs := make([]nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, 0, len(groupIDs)) + for _, gid := range groupIDs { + id := gid + groupRefs = append(groupRefs, nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{ + Id: &nb.ApprovalWorkflowApprovalWorkflowDefinitionId{Int32: &id}, + }) + } + + req := nb.ObjectPermissionRequest{ + Name: perm.Name, + ObjectTypes: perm.ObjectTypes, + Actions: actions, + Enabled: nb.PtrBool(perm.IsEnabled()), + Groups: groupRefs, + } + if perm.Description != "" { + req.Description = nb.PtrString(perm.Description) + } + if constraints != nil { + req.Constraints = constraints + } + + created, err := s.permissionSvc.Create(ctx, req) + if err != nil { + return fmt.Errorf("failed to create permission %q: %w", perm.Name, err) + } + log.Info("permission synced (created)", "name", perm.Name, "id", created.GetId()) + } + + return nil +} + +func (s *PermissionGroupSync) resolveGroupID(ctx context.Context, name string, cache map[string]int32) (int32, error) { + if id, ok := cache[name]; ok { + return id, nil + } + + group, err := s.groupSvc.GetOrCreate(ctx, name) + if err != nil { + return 0, err + } + if group == nil { + return 0, fmt.Errorf("failed to get or create group: %s", name) + } + + cache[name] = group.Id + return group.Id, nil +} From 8dc5185fe916f264f1fdc9840332a5a30f0fb10d Mon Sep 17 00:00:00 2001 From: Abhimanyu Sharma Date: Wed, 22 Jul 2026 17:37:26 +0530 Subject: [PATCH 2/2] fix: lint fix for pre-allocation --- go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go b/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go index 7ebca287f..67e4a5313 100644 --- a/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go +++ b/go/nautobotop/internal/nautobot/sync/admin/permissionGroup.go @@ -72,7 +72,7 @@ func (s *PermissionGroupSync) syncSinglePermission( return nil } - var groupIDs []int32 + groupIDs := make([]int32, 0, len(perm.Groups)) for _, groupName := range perm.Groups { gid, err := s.resolveGroupID(ctx, groupName, groupNameToID) if err != nil {