From a8fbe725a28a67d2fd451ad8b4d5f76b0761ff3f Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 17 Mar 2026 21:42:46 +0100 Subject: [PATCH] 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 --- admin-web/src/api/client.ts | 36 ++++ admin-web/src/app/App.tsx | 2 + admin-web/src/components/Layout.tsx | 1 + admin-web/src/components/Modal.tsx | 26 +++ admin-web/src/features/groups/GroupsPage.tsx | 187 ++++++++++++++++++ .../src/features/policies/PoliciesPage.tsx | 179 +++++++++++------ admin-web/src/features/users/UsersPage.tsx | 41 ++-- admin-web/src/styles/global.css | 78 ++++++++ backend/internal/app/app.go | 3 + backend/internal/gateway/repository.go | 4 +- backend/internal/group/handler.go | 117 +++++++++++ backend/internal/group/repository.go | 183 +++++++++++++++++ backend/internal/group/service.go | 31 +++ backend/internal/group/types.go | 29 +++ backend/internal/httpserver/router.go | 6 + backend/internal/policy/repository.go | 79 +++++--- backend/internal/policy/types.go | 1 + 17 files changed, 900 insertions(+), 103 deletions(-) create mode 100644 admin-web/src/components/Modal.tsx create mode 100644 admin-web/src/features/groups/GroupsPage.tsx create mode 100644 backend/internal/group/handler.go create mode 100644 backend/internal/group/repository.go create mode 100644 backend/internal/group/service.go create mode 100644 backend/internal/group/types.go diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index db4552f..cbb0272 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -56,9 +56,22 @@ export type Policy = { targets?: Array<{ type: string; id: string; + name?: string; }>; }; +export type Group = { + id: string; + name: string; + description: string; + members: Array<{ + id: string; + username: string; + display_name: string; + }>; + user_ids?: string[]; +}; + export type Gateway = { id: string; name: string; @@ -129,6 +142,29 @@ export const api = { request<{ ok: boolean }>(`/admin/users/${userId}`, { method: "DELETE" }), + groups: () => request("/admin/groups"), + createGroup: (payload: { + name: string; + description: string; + user_ids: string[]; + }) => + request("/admin/groups", { + method: "POST", + body: JSON.stringify(payload) + }), + updateGroup: (groupId: string, payload: { + name?: string; + description?: string; + user_ids?: string[]; + }) => + request(`/admin/groups/${groupId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + deleteGroup: (groupId: string) => + request<{ ok: boolean }>(`/admin/groups/${groupId}`, { + method: "DELETE" + }), devices: () => request("/admin/devices"), deviceProfile: (deviceId: string) => request(`/admin/devices/${deviceId}/profile`), revokeDevice: (deviceId: string) => diff --git a/admin-web/src/app/App.tsx b/admin-web/src/app/App.tsx index 738caf8..60e9f41 100644 --- a/admin-web/src/app/App.tsx +++ b/admin-web/src/app/App.tsx @@ -8,6 +8,7 @@ import { LoginPage } from "../features/auth/LoginPage"; import { DashboardPage } from "../features/dashboard/DashboardPage"; import { DevicesPage } from "../features/devices/DevicesPage"; import { GatewaysPage } from "../features/gateways/GatewaysPage"; +import { GroupsPage } from "../features/groups/GroupsPage"; import { PoliciesPage } from "../features/policies/PoliciesPage"; import { SettingsPage } from "../features/settings/SettingsPage"; import { UsersPage } from "../features/users/UsersPage"; @@ -41,6 +42,7 @@ export function App() { : }> } /> } /> + } /> } /> } /> } /> diff --git a/admin-web/src/components/Layout.tsx b/admin-web/src/components/Layout.tsx index e87103b..4efc400 100644 --- a/admin-web/src/components/Layout.tsx +++ b/admin-web/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom"; const items = [ ["Dashboard", "/"], ["Users", "/users"], + ["Groups", "/groups"], ["Devices", "/devices"], ["Policies", "/policies"], ["Gateways", "/gateways"], diff --git a/admin-web/src/components/Modal.tsx b/admin-web/src/components/Modal.tsx new file mode 100644 index 0000000..cf7b240 --- /dev/null +++ b/admin-web/src/components/Modal.tsx @@ -0,0 +1,26 @@ +import { PropsWithChildren } from "react"; + +type ModalProps = PropsWithChildren<{ + title: string; + subtitle?: string; + onClose: () => void; +}>; + +export function Modal({ title, subtitle, onClose, children }: ModalProps) { + return ( +
+
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}> +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ +
+ {children} +
+
+ ); +} diff --git a/admin-web/src/features/groups/GroupsPage.tsx b/admin-web/src/features/groups/GroupsPage.tsx new file mode 100644 index 0000000..bc00027 --- /dev/null +++ b/admin-web/src/features/groups/GroupsPage.tsx @@ -0,0 +1,187 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../../api/client"; +import { Modal } from "../../components/Modal"; +import { Page } from "../../components/Page"; +import { Table } from "../../components/Table"; + +const columns = [ + { key: "name", label: "Group" }, + { key: "description", label: "Description" }, + { key: "members", label: "Members" }, + { key: "actions", label: "Actions" } +]; + +export function GroupsPage() { + const queryClient = useQueryClient(); + const groupsQuery = useQuery({ + queryKey: ["groups"], + queryFn: api.groups + }); + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: api.users + }); + const [form, setForm] = useState({ + name: "", + description: "", + user_ids: [] as string[] + }); + const [editingGroupId, setEditingGroupId] = useState(null); + const [editForm, setEditForm] = useState({ + name: "", + description: "", + user_ids: [] as string[] + }); + + const createMutation = useMutation({ + mutationFn: api.createGroup, + onSuccess: () => { + setForm({ name: "", description: "", user_ids: [] }); + void queryClient.invalidateQueries({ queryKey: ["groups"] }); + } + }); + + const updateMutation = useMutation({ + mutationFn: ({ groupId, payload }: { groupId: string; payload: typeof editForm }) => api.updateGroup(groupId, payload), + onSuccess: () => { + setEditingGroupId(null); + setEditForm({ name: "", description: "", user_ids: [] }); + void queryClient.invalidateQueries({ queryKey: ["groups"] }); + } + }); + + const deleteMutation = useMutation({ + mutationFn: api.deleteGroup, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["groups"] }); + } + }); + + const rows = groupsQuery.data?.map((group) => ({ + id: group.id, + name: group.name, + description: group.description || "-", + members: group.members.length ? group.members.map((member) => member.username).join(", ") : "No members" + })) ?? []; + + function toggleUser(userId: string, editing = false) { + if (editing) { + setEditForm((value) => ({ + ...value, + user_ids: value.user_ids.includes(userId) + ? value.user_ids.filter((id) => id !== userId) + : [...value.user_ids, userId] + })); + return; + } + + setForm((value) => ({ + ...value, + user_ids: value.user_ids.includes(userId) + ? value.user_ids.filter((id) => id !== userId) + : [...value.user_ids, userId] + })); + } + + function onSubmit(event: FormEvent) { + event.preventDefault(); + createMutation.mutate(form); + } + + function startEdit(groupId: string) { + const group = groupsQuery.data?.find((item) => item.id === groupId); + if (!group) { + return; + } + setEditingGroupId(groupId); + setEditForm({ + name: group.name, + description: group.description, + user_ids: group.members.map((member) => member.id) + }); + } + + function onEditSubmit(event: FormEvent) { + event.preventDefault(); + if (!editingGroupId) { + return; + } + updateMutation.mutate({ + groupId: editingGroupId, + payload: editForm + }); + } + + return ( + Reusable targets} + > +
+
+ setForm((value) => ({ ...value, name: event.target.value }))} /> + setForm((value) => ({ ...value, description: event.target.value }))} /> +
+
+

Members

+
+ {(usersQuery.data ?? []).map((user) => ( + + ))} +
+
+
+ +
+
+ {groupsQuery.isError ?

Unable to load groups from the API.

: null} + {createMutation.isError ?

Unable to create group.

: null} + {updateMutation.isError ?

Unable to update group.

: null} + {deleteMutation.isError ?

Unable to delete group.

: null} + { + if (column.key === "actions") { + return ( +
+ + +
+ ); + } + return {row[column.key as keyof (typeof rows)[number]]}; + }} + /> + {editingGroupId ? ( + setEditingGroupId(null)}> +
+ setEditForm((value) => ({ ...value, name: event.target.value }))} /> + setEditForm((value) => ({ ...value, description: event.target.value }))} /> +
+

Members

+
+ {(usersQuery.data ?? []).map((user) => ( + + ))} +
+
+
+ + +
+ +
+ ) : null} + + ); +} diff --git a/admin-web/src/features/policies/PoliciesPage.tsx b/admin-web/src/features/policies/PoliciesPage.tsx index 4d70d26..860f62d 100644 --- a/admin-web/src/features/policies/PoliciesPage.tsx +++ b/admin-web/src/features/policies/PoliciesPage.tsx @@ -2,6 +2,7 @@ import { FormEvent, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; +import { Modal } from "../../components/Modal"; import { Page } from "../../components/Page"; import { Table } from "../../components/Table"; @@ -23,36 +24,45 @@ export function PoliciesPage() { queryKey: ["users"], queryFn: api.users }); + const groupsQuery = useQuery({ + queryKey: ["groups"], + queryFn: api.groups + }); const [form, setForm] = useState({ name: "", description: "", destinations: "", - targetUserId: "", + targetType: "user", + targetIds: [] as string[], fullTunnel: false }); + const [createOpen, setCreateOpen] = useState(false); const [editingPolicyId, setEditingPolicyId] = useState(null); const [editForm, setEditForm] = useState({ name: "", description: "", destinations: "", fullTunnel: false, - isActive: true + isActive: true, + targetType: "user", + targetIds: [] as string[] }); const createMutation = useMutation({ mutationFn: api.createPolicy, onSuccess: () => { - setForm({ name: "", description: "", destinations: "", targetUserId: "", fullTunnel: false }); + setCreateOpen(false); + setForm({ name: "", description: "", destinations: "", targetType: "user", targetIds: [], fullTunnel: false }); void queryClient.invalidateQueries({ queryKey: ["policies"] }); } }); const updateMutation = useMutation({ - mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean } }) => + mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean; targets: Array<{ type: string; id: string }> } }) => api.updatePolicy(policyId, payload), onSuccess: () => { setEditingPolicyId(null); - setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true }); + setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true, targetType: "user", targetIds: [] }); void queryClient.invalidateQueries({ queryKey: ["policies"] }); } }); @@ -67,16 +77,19 @@ export function PoliciesPage() { const rows = query.data?.map((policy) => ({ id: policy.id, name: policy.name, - targets: policy.targets?.length ? `${policy.targets.length} target(s)` : "assigned targets", + targets: policy.targets?.length ? policy.targets.map((target) => `${target.type}: ${target.name ?? target.id}`).join(", ") : "No targets", destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"), mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel" })) ?? []; const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]); + const selectableGroups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); + const selectableTargets = form.targetType === "group" ? selectableGroups : selectableUsers; + const editableTargets = editForm.targetType === "group" ? selectableGroups : selectableUsers; function onSubmit(event: FormEvent) { event.preventDefault(); - if (!form.targetUserId) { + if (!form.targetIds.length) { return; } createMutation.mutate({ @@ -86,7 +99,7 @@ export function PoliciesPage() { effect: "allow", full_tunnel: form.fullTunnel, destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean), - targets: [{ type: "user", id: form.targetUserId }] + targets: form.targetIds.map((id) => ({ type: form.targetType, id })) }); } @@ -96,12 +109,15 @@ export function PoliciesPage() { return; } setEditingPolicyId(policyId); + const targetType = policy.targets?.[0]?.type ?? "user"; setEditForm({ name: policy.name, description: policy.description, destinations: policy.destinations?.join(", ") ?? "", fullTunnel: policy.full_tunnel, - isActive: policy.is_active + isActive: policy.is_active, + targetType, + targetIds: policy.targets?.filter((target) => target.type === targetType).map((target) => target.id) ?? [] }); } @@ -117,68 +133,37 @@ export function PoliciesPage() { description: editForm.description, destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean), full_tunnel: editForm.fullTunnel, - is_active: editForm.isActive + is_active: editForm.isActive, + targets: editForm.targetIds.map((id) => ({ type: editForm.targetType, id })) } }); } + function toggleTarget(id: string, editing = false) { + const setter = editing ? setEditForm : setForm; + setter((value) => ({ + ...value, + targetIds: value.targetIds.includes(id) + ? value.targetIds.filter((item) => item !== id) + : [...value.targetIds, id] + })); + } + return ( Gateway enforced} + actions={( +
+ Gateway enforced + +
+ )} > -
- setForm((value) => ({ ...value, name: event.target.value }))} /> - setForm((value) => ({ ...value, description: event.target.value }))} /> - setForm((value) => ({ ...value, destinations: event.target.value }))} - disabled={form.fullTunnel} - /> - - - - {query.isError ?

Unable to load policies from the API.

: null} {createMutation.isError ?

Unable to create policy.

: null} {updateMutation.isError ?

Unable to update policy.

: null} {deleteMutation.isError ?

Unable to delete policy.

: null} - {editingPolicyId ? ( -
- setEditForm((value) => ({ ...value, name: event.target.value }))} /> - setEditForm((value) => ({ ...value, description: event.target.value }))} /> - setEditForm((value) => ({ ...value, destinations: event.target.value }))} - disabled={editForm.fullTunnel} - /> - - -
- - -
- - ) : null}
{row[column.key as keyof (typeof rows)[number]]}; }} /> + {createOpen ? ( + setCreateOpen(false)}> +
+ setForm((value) => ({ ...value, name: event.target.value }))} /> + setForm((value) => ({ ...value, description: event.target.value }))} /> + +
+

{form.targetType === "group" ? "Target groups" : "Target users"}

+
+ {selectableTargets.map((target) => ( + + ))} +
+
+ setForm((value) => ({ ...value, destinations: event.target.value }))} + disabled={form.fullTunnel} + /> + +
+ + +
+ +
+ ) : null} + {editingPolicyId ? ( + setEditingPolicyId(null)}> +
+ setEditForm((value) => ({ ...value, name: event.target.value }))} /> + setEditForm((value) => ({ ...value, description: event.target.value }))} /> + +
+

{editForm.targetType === "group" ? "Target groups" : "Target users"}

+
+ {editableTargets.map((target) => ( + + ))} +
+
+ setEditForm((value) => ({ ...value, destinations: event.target.value }))} + disabled={editForm.fullTunnel} + /> + + +
+ + +
+ +
+ ) : null} ); } diff --git a/admin-web/src/features/users/UsersPage.tsx b/admin-web/src/features/users/UsersPage.tsx index adad485..d5cc8ba 100644 --- a/admin-web/src/features/users/UsersPage.tsx +++ b/admin-web/src/features/users/UsersPage.tsx @@ -2,6 +2,7 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; +import { Modal } from "../../components/Modal"; import { Page } from "../../components/Page"; import { Table } from "../../components/Table"; @@ -121,25 +122,6 @@ export function UsersPage() { {createMutation.isError ?

Unable to create user.

: null} {updateMutation.isError ?

Unable to update user.

: null} {deleteMutation.isError ?

Unable to delete user.

: null} - {editingUserId ? ( - - setEditForm((value) => ({ ...value, display_name: event.target.value }))} /> - setEditForm((value) => ({ ...value, email: event.target.value }))} /> - - setEditForm((value) => ({ ...value, password: event.target.value }))} /> - -
- - -
- - ) : null}
{row[column.key as keyof (typeof rows)[number]]}; }} /> + {editingUserId ? ( + setEditingUserId(null)}> +
+ setEditForm((value) => ({ ...value, display_name: event.target.value }))} /> + setEditForm((value) => ({ ...value, email: event.target.value }))} /> + + setEditForm((value) => ({ ...value, password: event.target.value }))} /> + +
+ + +
+ +
+ ) : null} ); } diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css index 7c2889d..772f513 100644 --- a/admin-web/src/styles/global.css +++ b/admin-web/src/styles/global.css @@ -230,6 +230,30 @@ button { align-items: center; } +.stacked-form { + display: grid; + gap: 14px; +} + +.stacked-form input, +.stacked-form select { + width: 100%; + border: 1px solid var(--line); + background: rgba(8, 14, 26, 0.86); + color: var(--text); + border-radius: 14px; + padding: 12px 14px; +} + +.form-grid { + display: grid; + gap: 12px; +} + +.form-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .inline-form input, .inline-form select { width: 100%; @@ -309,6 +333,60 @@ button { gap: 12px; } +.selection-panel { + display: grid; + gap: 10px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(8, 14, 26, 0.56); +} + +.selection-list { + display: grid; + gap: 10px; + max-height: 240px; + overflow: auto; +} + +.selection-item { + display: flex; + align-items: center; + gap: 10px; + color: var(--text); +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgba(2, 6, 15, 0.72); + backdrop-filter: blur(10px); + z-index: 50; +} + +.modal-card { + width: min(640px, 100%); + max-height: calc(100vh - 48px); + overflow: auto; + display: grid; + gap: 18px; + padding: 24px; + background: #111b30; + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.modal-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + @media (max-width: 960px) { .shell { grid-template-columns: 1fr; diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 2fe41a6..c476efe 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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), diff --git a/backend/internal/gateway/repository.go b/backend/internal/gateway/repository.go index a66a650..0296fff 100644 --- a/backend/internal/gateway/repository.go +++ b/backend/internal/gateway/repository.go @@ -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' diff --git a/backend/internal/group/handler.go b/backend/internal/group/handler.go new file mode 100644 index 0000000..facb295 --- /dev/null +++ b/backend/internal/group/handler.go @@ -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}) +} diff --git a/backend/internal/group/repository.go b/backend/internal/group/repository.go new file mode 100644 index 0000000..a7d5e5a --- /dev/null +++ b/backend/internal/group/repository.go @@ -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() +} diff --git a/backend/internal/group/service.go b/backend/internal/group/service.go new file mode 100644 index 0000000..e4ae130 --- /dev/null +++ b/backend/internal/group/service.go @@ -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) +} diff --git a/backend/internal/group/types.go b/backend/internal/group/types.go new file mode 100644 index 0000000..3019aab --- /dev/null +++ b/backend/internal/group/types.go @@ -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"` +} diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index eda3a34..cd3bd6a 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -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) diff --git a/backend/internal/policy/repository.go b/backend/internal/policy/repository.go index 40ab426..5fb8583 100644 --- a/backend/internal/policy/repository.go +++ b/backend/internal/policy/repository.go @@ -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() +} diff --git a/backend/internal/policy/types.go b/backend/internal/policy/types.go index cf98c54..5f384bd 100644 --- a/backend/internal/policy/types.go +++ b/backend/internal/policy/types.go @@ -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 {