feat: add groups management with CRUD operations and policy target assignment

Add Group type with id, name, description, members array and optional user_ids field. Add name field to policy targets for display. Add groups API client methods for list, create, update and delete operations. Add GroupsPage component with create form, edit modal, member selection and table view. Add groups route and navigation item to Layout. Add reusable Modal component with title, subtitle and close handler. Update
This commit is contained in:
2026-03-17 21:42:46 +01:00
parent 0986a36aca
commit a8fbe725a2
17 changed files with 900 additions and 103 deletions

View File

@@ -12,6 +12,7 @@ import (
"nexavpn/backend/internal/db"
"nexavpn/backend/internal/device"
"nexavpn/backend/internal/gateway"
"nexavpn/backend/internal/group"
"nexavpn/backend/internal/httpserver"
"nexavpn/backend/internal/ipam"
"nexavpn/backend/internal/policy"
@@ -35,6 +36,7 @@ func New(cfg config.Config) (*App, error) {
authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL)
userService := user.NewService(user.NewPGRepository(pool))
groupService := group.NewService(group.NewPGRepository(pool))
policyService := policy.NewService(policy.NewPGRepository(pool))
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
@@ -44,6 +46,7 @@ func New(cfg config.Config) (*App, error) {
Auth: auth.NewHandler(authService, auditService),
User: user.NewHandler(userService, auditService),
Device: device.NewHandler(deviceService, auditService),
Group: group.NewHandler(groupService, auditService),
Policy: policy.NewHandler(policyService, auditService),
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
Audit: audit.NewHandler(auditService),

View File

@@ -92,9 +92,11 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
coalesce(array_agg(distinct pd.destination::text) filter (where pd.destination is not null), '{}')
from devices d
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
left join group_memberships gm on gm.user_id = d.user_id
left join policy_targets pt on (
(pt.target_type = 'device' and pt.target_id = d.id) or
(pt.target_type = 'user' and pt.target_id = d.user_id)
(pt.target_type = 'user' and pt.target_id = d.user_id) or
(pt.target_type = 'group' and pt.target_id = gm.group_id)
)
left join policy_destinations pd on pd.policy_id = pt.policy_id
where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active'

View File

@@ -0,0 +1,117 @@
package group
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"nexavpn/backend/internal/apiutil"
"nexavpn/backend/internal/audit"
"nexavpn/backend/internal/requestctx"
)
type Handler struct {
service *Service
audit *audit.Service
}
func NewHandler(service *Service, auditService *audit.Service) *Handler {
return &Handler{service: service, audit: auditService}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.service.List(r.Context())
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "groups_list_failed", "unable to list groups")
return
}
apiutil.JSON(w, http.StatusOK, items)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var input CreateRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
item, err := h.service.Create(r.Context(), input)
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "group_create_failed", "unable to create group")
return
}
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &claims.UserID,
EntityType: "group",
EntityID: &item.ID,
EventType: "admin.group.created",
Status: "success",
Message: "admin created group",
})
}
apiutil.JSON(w, http.StatusCreated, item)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
return
}
var input UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
item, err := h.service.Update(r.Context(), groupID, input)
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "group_update_failed", "unable to update group")
return
}
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &claims.UserID,
EntityType: "group",
EntityID: &groupID,
EventType: "admin.group.updated",
Status: "success",
Message: "admin updated group",
})
}
apiutil.JSON(w, http.StatusOK, item)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
return
}
if err := h.service.Delete(r.Context(), groupID); err != nil {
apiutil.Error(w, http.StatusInternalServerError, "group_delete_failed", "unable to delete group")
return
}
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
_ = h.audit.Record(r.Context(), audit.Entry{
ActorUserID: &claims.UserID,
EntityType: "group",
EntityID: &groupID,
EventType: "admin.group.deleted",
Status: "success",
Message: "admin deleted group",
})
}
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
}

View File

@@ -0,0 +1,183 @@
package group
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
List(ctx context.Context) ([]Group, error)
Create(ctx context.Context, input CreateRequest) (Group, error)
Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error)
Delete(ctx context.Context, groupID uuid.UUID) error
}
type PGRepository struct {
db *pgxpool.Pool
}
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
return &PGRepository{db: db}
}
func (r *PGRepository) List(ctx context.Context) ([]Group, error) {
rows, err := r.db.Query(ctx, `
select id, name, description
from groups
where deleted_at is null
order by created_at desc
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Group
for rows.Next() {
var item Group
if err := rows.Scan(&item.ID, &item.Name, &item.Description); err != nil {
return nil, err
}
members, err := r.listMembers(ctx, item.ID)
if err != nil {
return nil, err
}
item.Members = members
for _, member := range members {
item.UserIDs = append(item.UserIDs, member.ID)
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *PGRepository) Create(ctx context.Context, input CreateRequest) (Group, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return Group{}, err
}
defer tx.Rollback(ctx)
groupID := uuid.New()
if _, err := tx.Exec(ctx, `
insert into groups (id, name, description)
values ($1, $2, $3)
`, groupID, input.Name, input.Description); err != nil {
return Group{}, err
}
for _, userID := range input.UserIDs {
if _, err := tx.Exec(ctx, `
insert into group_memberships (id, group_id, user_id)
values ($1, $2, $3)
`, uuid.New(), groupID, userID); err != nil {
return Group{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return Group{}, err
}
return r.getByID(ctx, groupID)
}
func (r *PGRepository) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return Group{}, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
update groups
set name = coalesce($2, name),
description = coalesce($3, description),
updated_at = now()
where id = $1 and deleted_at is null
`, groupID, input.Name, input.Description); err != nil {
return Group{}, err
}
if input.UserIDs != nil {
if _, err := tx.Exec(ctx, `delete from group_memberships where group_id = $1`, groupID); err != nil {
return Group{}, err
}
for _, userID := range input.UserIDs {
if _, err := tx.Exec(ctx, `
insert into group_memberships (id, group_id, user_id)
values ($1, $2, $3)
`, uuid.New(), groupID, userID); err != nil {
return Group{}, err
}
}
}
if err := tx.Commit(ctx); err != nil {
return Group{}, err
}
return r.getByID(ctx, groupID)
}
func (r *PGRepository) Delete(ctx context.Context, groupID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
update groups
set deleted_at = now(), updated_at = now()
where id = $1 and deleted_at is null
`, groupID)
return err
}
func (r *PGRepository) getByID(ctx context.Context, groupID uuid.UUID) (Group, error) {
row := r.db.QueryRow(ctx, `
select id, name, description
from groups
where id = $1 and deleted_at is null
`, groupID)
var item Group
if err := row.Scan(&item.ID, &item.Name, &item.Description); err != nil {
return Group{}, err
}
members, err := r.listMembers(ctx, item.ID)
if err != nil {
return Group{}, err
}
item.Members = members
for _, member := range members {
item.UserIDs = append(item.UserIDs, member.ID)
}
return item, nil
}
func (r *PGRepository) listMembers(ctx context.Context, groupID uuid.UUID) ([]Member, error) {
rows, err := r.db.Query(ctx, `
select u.id, u.username, u.display_name
from group_memberships gm
join users u on u.id = gm.user_id
where gm.group_id = $1 and u.deleted_at is null
order by u.username asc
`, groupID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Member
for rows.Next() {
var item Member
if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}

View File

@@ -0,0 +1,31 @@
package group
import (
"context"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) List(ctx context.Context) ([]Group, error) {
return s.repo.List(ctx)
}
func (s *Service) Create(ctx context.Context, input CreateRequest) (Group, error) {
return s.repo.Create(ctx, input)
}
func (s *Service) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
return s.repo.Update(ctx, groupID, input)
}
func (s *Service) Delete(ctx context.Context, groupID uuid.UUID) error {
return s.repo.Delete(ctx, groupID)
}

View File

@@ -0,0 +1,29 @@
package group
import "github.com/google/uuid"
type Member struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
}
type Group struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Members []Member `json:"members"`
UserIDs []uuid.UUID `json:"user_ids,omitempty"`
}
type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
UserIDs []uuid.UUID `json:"user_ids"`
}
type UpdateRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
UserIDs []uuid.UUID `json:"user_ids"`
}

View File

@@ -10,6 +10,7 @@ import (
"nexavpn/backend/internal/audit"
"nexavpn/backend/internal/device"
"nexavpn/backend/internal/gateway"
"nexavpn/backend/internal/group"
"nexavpn/backend/internal/policy"
"nexavpn/backend/internal/user"
)
@@ -20,6 +21,7 @@ type Handlers struct {
Device *device.Handler
Policy *policy.Handler
Gateway *gateway.Handler
Group *group.Handler
Audit *audit.Handler
}
@@ -60,6 +62,10 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
r.Get("/groups", handlers.Group.List)
r.Post("/groups", handlers.Group.Create)
r.Patch("/groups/{id}", handlers.Group.Update)
r.Delete("/groups/{id}", handlers.Group.Delete)
r.Get("/policies", handlers.Policy.List)
r.Post("/policies", handlers.Policy.Create)
r.Patch("/policies/{id}", handlers.Policy.Update)

View File

@@ -52,6 +52,11 @@ func (r *PGRepository) List(ctx context.Context) ([]Policy, error) {
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil {
return nil, err
}
targets, err := r.listTargets(ctx, item.ID)
if err != nil {
return nil, err
}
item.Targets = targets
items = append(items, item)
}
return items, rows.Err()
@@ -95,18 +100,7 @@ func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdB
return Policy{}, err
}
inputPolicy := Policy{
ID: policyID,
Name: input.Name,
Description: input.Description,
Priority: input.Priority,
Effect: input.Effect,
FullTunnel: input.FullTunnel,
IsActive: true,
Destinations: input.Destinations,
Targets: input.Targets,
}
return inputPolicy, nil
return r.getByID(ctx, policyID)
}
func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) {
@@ -164,19 +158,7 @@ func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input Upd
return Policy{}, err
}
items, err := r.List(ctx)
if err != nil {
return Policy{}, err
}
for _, item := range items {
if item.ID == policyID {
if input.Targets != nil {
item.Targets = input.Targets
}
return item, nil
}
}
return Policy{}, errors.New("policy not found after update")
return r.getByID(ctx, policyID)
}
func (r *PGRepository) Delete(ctx context.Context, policyID uuid.UUID) error {
@@ -195,6 +177,10 @@ func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID
and p.effect = 'allow'
and (
(pt.target_type = 'user' and pt.target_id = $1)
or (pt.target_type = 'group' and exists (
select 1 from group_memberships gm
where gm.group_id = pt.target_id and gm.user_id = $1
))
`
args := []any{userID}
if deviceID != nil {
@@ -219,3 +205,46 @@ func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID
}
return destinations, rows.Err()
}
func (r *PGRepository) getByID(ctx context.Context, policyID uuid.UUID) (Policy, error) {
items, err := r.List(ctx)
if err != nil {
return Policy{}, err
}
for _, item := range items {
if item.ID == policyID {
return item, nil
}
}
return Policy{}, errors.New("policy not found")
}
func (r *PGRepository) listTargets(ctx context.Context, policyID uuid.UUID) ([]Target, error) {
rows, err := r.db.Query(ctx, `
select
pt.target_type,
pt.target_id,
coalesce(u.username, g.name, d.name, '')
from policy_targets pt
left join users u on pt.target_type = 'user' and u.id = pt.target_id and u.deleted_at is null
left join groups g on pt.target_type = 'group' and g.id = pt.target_id and g.deleted_at is null
left join devices d on pt.target_type = 'device' and d.id = pt.target_id and d.deleted_at is null
where pt.policy_id = $1
order by pt.created_at asc
`, policyID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Target
for rows.Next() {
var item Target
if err := rows.Scan(&item.Type, &item.ID, &item.Name); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}

View File

@@ -5,6 +5,7 @@ import "github.com/google/uuid"
type Target struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
}
type Policy struct {