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 {