Changeset 0.16.0 (#74)
This commit is contained in:
365
server/handlers/groups.go
Normal file
365
server/handlers/groups.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request types ───────────────────────────
|
||||
|
||||
type createGroupRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=200"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Scope string `json:"scope" binding:"required,oneof=global team"`
|
||||
TeamID *string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
type updateGroupRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type addGroupMemberRequest struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
}
|
||||
|
||||
type setResourceGrantRequest struct {
|
||||
GrantScope string `json:"grant_scope" binding:"required,oneof=team_only global groups"`
|
||||
GrantedGroups []string `json:"granted_groups,omitempty"` // required when grant_scope=groups
|
||||
}
|
||||
|
||||
// ── Handler ─────────────────────────────────
|
||||
|
||||
type GroupHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewGroupHandler(s store.Stores) *GroupHandler {
|
||||
return &GroupHandler{stores: s}
|
||||
}
|
||||
|
||||
// ── Admin: List All Groups ──────────────────
|
||||
|
||||
func (h *GroupHandler) ListGroups(c *gin.Context) {
|
||||
groups, err := h.stores.Groups.ListAll(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []models.Group{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": groups})
|
||||
}
|
||||
|
||||
// ── Admin: Create Group ─────────────────────
|
||||
|
||||
func (h *GroupHandler) CreateGroup(c *gin.Context) {
|
||||
actorID := getUserID(c)
|
||||
|
||||
var req createGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scope constraints
|
||||
if req.Scope == models.ScopeTeam && (req.TeamID == nil || *req.TeamID == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped groups"})
|
||||
return
|
||||
}
|
||||
if req.Scope == models.ScopeGlobal && req.TeamID != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id must be null for global groups"})
|
||||
return
|
||||
}
|
||||
|
||||
g := &models.Group{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Scope: req.Scope,
|
||||
TeamID: req.TeamID,
|
||||
CreatedBy: actorID,
|
||||
}
|
||||
|
||||
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, g)
|
||||
AuditLog(c, "group.create", "group", g.ID, map[string]interface{}{
|
||||
"name": g.Name, "scope": g.Scope,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin: Get Group ────────────────────────
|
||||
|
||||
func (h *GroupHandler) GetGroup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
g, err := h.stores.Groups.GetByID(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, g)
|
||||
}
|
||||
|
||||
// ── Admin: Update Group ─────────────────────
|
||||
|
||||
func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var req updateGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == nil && req.Description == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.stores.Groups.Update(c.Request.Context(), id, req.Name, req.Description)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "group name already exists in this scope"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "group.update", "group", id, nil)
|
||||
}
|
||||
|
||||
// ── Admin: Delete Group ─────────────────────
|
||||
|
||||
func (h *GroupHandler) DeleteGroup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
err := h.stores.Groups.Delete(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "group.delete", "group", id, nil)
|
||||
}
|
||||
|
||||
// ── Members: List ───────────────────────────
|
||||
|
||||
func (h *GroupHandler) ListMembers(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
members, err := h.stores.Groups.ListMembers(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
if members == nil {
|
||||
members = []models.GroupMember{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": members})
|
||||
}
|
||||
|
||||
// ── Members: Add ────────────────────────────
|
||||
|
||||
func (h *GroupHandler) AddMember(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
actorID := getUserID(c)
|
||||
|
||||
var req addGroupMemberRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify group exists
|
||||
if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{
|
||||
"user_id": req.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Members: Remove ─────────────────────────
|
||||
|
||||
func (h *GroupHandler) RemoveMember(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
userID := c.Param("userId")
|
||||
|
||||
err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove member"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
})
|
||||
}
|
||||
|
||||
// ── User: My Groups ─────────────────────────
|
||||
|
||||
func (h *GroupHandler) MyGroups(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
groups, err := h.stores.Groups.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []models.Group{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": groups})
|
||||
}
|
||||
|
||||
// ── Team Admin: List Team Groups ────────────
|
||||
|
||||
func (h *GroupHandler) ListTeamGroups(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
groups, err := h.stores.Groups.ListForTeam(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []models.Group{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": groups})
|
||||
}
|
||||
|
||||
// ── Resource Grants ─────────────────────────
|
||||
|
||||
// GetResourceGrant returns the grant for a resource.
|
||||
// GET /api/v1/admin/grants/:type/:id
|
||||
func (h *GroupHandler) GetResourceGrant(c *gin.Context) {
|
||||
resourceType := c.Param("type")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
grant, err := h.stores.ResourceGrants.Get(c.Request.Context(), resourceType, resourceID)
|
||||
if err == sql.ErrNoRows {
|
||||
// No grant = default team_only behavior
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"grant_scope": models.GrantScopeTeamOnly,
|
||||
"granted_groups": []string{},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, grant)
|
||||
}
|
||||
|
||||
// SetResourceGrant creates or updates a grant for a resource.
|
||||
// PUT /api/v1/admin/grants/:type/:id
|
||||
func (h *GroupHandler) SetResourceGrant(c *gin.Context) {
|
||||
resourceType := c.Param("type")
|
||||
resourceID := c.Param("id")
|
||||
actorID := getUserID(c)
|
||||
|
||||
var req setResourceGrantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate: groups scope requires at least one group
|
||||
if req.GrantScope == models.GrantScopeGroups && len(req.GrantedGroups) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "granted_groups required when grant_scope is 'groups'"})
|
||||
return
|
||||
}
|
||||
|
||||
// team_only = delete any existing grant (reverts to default scope behavior)
|
||||
if req.GrantScope == models.GrantScopeTeamOnly {
|
||||
_ = h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "grant.revoke", resourceType, resourceID, nil)
|
||||
return
|
||||
}
|
||||
|
||||
grant := &models.ResourceGrant{
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
GrantScope: req.GrantScope,
|
||||
GrantedGroups: req.GrantedGroups,
|
||||
CreatedBy: actorID,
|
||||
}
|
||||
|
||||
if err := h.stores.ResourceGrants.Set(c.Request.Context(), grant); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set grant"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, grant)
|
||||
AuditLog(c, "grant.set", resourceType, resourceID, map[string]interface{}{
|
||||
"grant_scope": req.GrantScope,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteResourceGrant removes a grant for a resource.
|
||||
// DELETE /api/v1/admin/grants/:type/:id
|
||||
func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
|
||||
resourceType := c.Param("type")
|
||||
resourceID := c.Param("id")
|
||||
|
||||
err := h.stores.ResourceGrants.Delete(c.Request.Context(), resourceType, resourceID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "grant not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "grant.delete", resourceType, resourceID, nil)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
)
|
||||
@@ -185,6 +186,25 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
|
||||
// Admin groups (v0.16.0)
|
||||
groupAdm := NewGroupHandler(stores)
|
||||
admin.GET("/groups", groupAdm.ListGroups)
|
||||
admin.POST("/groups", groupAdm.CreateGroup)
|
||||
admin.GET("/groups/:id", groupAdm.GetGroup)
|
||||
admin.PUT("/groups/:id", groupAdm.UpdateGroup)
|
||||
admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
|
||||
admin.GET("/groups/:id/members", groupAdm.ListMembers)
|
||||
admin.POST("/groups/:id/members", groupAdm.AddMember)
|
||||
admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)
|
||||
|
||||
// Admin resource grants (v0.16.0)
|
||||
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||
|
||||
// User groups (v0.16.0)
|
||||
protected.GET("/groups/mine", groupAdm.MyGroups)
|
||||
|
||||
// Admin roles
|
||||
rolesH := NewRolesHandler(stores, roleResolver)
|
||||
admin.GET("/roles", rolesH.ListRoles)
|
||||
@@ -2443,3 +2463,397 @@ func TestIntegration_ChannelKBLinking(t *testing.T) {
|
||||
t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData))
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// GROUPS + RESOURCE GRANTS (v0.16.0)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestGroupCRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
_, adminToken := h.createAdminUser("gadmin", "gadmin@test.com")
|
||||
|
||||
// ── Create global group ──
|
||||
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Engineering",
|
||||
"description": "All engineers",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created models.Group
|
||||
decode(w, &created)
|
||||
if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" {
|
||||
t.Fatalf("unexpected group: %+v", created)
|
||||
}
|
||||
|
||||
// ── List groups ──
|
||||
w = h.request("GET", "/api/v1/admin/groups", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list groups: want 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
data := listResp["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 group, got %d", len(data))
|
||||
}
|
||||
|
||||
// ── Get group ──
|
||||
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get group: want 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Update group ──
|
||||
w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{
|
||||
"name": "Platform Engineering",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify update
|
||||
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
||||
var updated models.Group
|
||||
decode(w, &updated)
|
||||
if updated.Name != "Platform Engineering" {
|
||||
t.Fatalf("name should be updated, got %q", updated.Name)
|
||||
}
|
||||
|
||||
// ── Duplicate name conflict ──
|
||||
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Platform Engineering",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate name: want 409, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Delete group ──
|
||||
w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete group: want 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("deleted group: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMembers(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
_, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
userToken := makeToken(userID, "gmuser@test.com", "user")
|
||||
|
||||
// Create group
|
||||
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Testers",
|
||||
"scope": "global",
|
||||
})
|
||||
var group models.Group
|
||||
decode(w, &group)
|
||||
|
||||
// ── Add member ──
|
||||
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── List members ──
|
||||
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list members: want 200, got %d", w.Code)
|
||||
}
|
||||
var memberResp map[string]interface{}
|
||||
decode(w, &memberResp)
|
||||
members := memberResp["data"].([]interface{})
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(members))
|
||||
}
|
||||
m := members[0].(map[string]interface{})
|
||||
if m["username"] != "gmuser" {
|
||||
t.Fatalf("expected username gmuser, got %v", m["username"])
|
||||
}
|
||||
|
||||
// ── My groups (user endpoint) ──
|
||||
w = h.request("GET", "/api/v1/groups/mine", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("my groups: want 200, got %d", w.Code)
|
||||
}
|
||||
var myResp map[string]interface{}
|
||||
decode(w, &myResp)
|
||||
myGroups := myResp["data"].([]interface{})
|
||||
if len(myGroups) != 1 {
|
||||
t.Fatalf("user should be in 1 group, got %d", len(myGroups))
|
||||
}
|
||||
|
||||
// ── Idempotent add (no error) ──
|
||||
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("idempotent add: want 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Remove member ──
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify removed
|
||||
w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
|
||||
decode(w, &memberResp)
|
||||
members = memberResp["data"].([]interface{})
|
||||
if len(members) != 0 {
|
||||
t.Fatalf("expected 0 members after removal, got %d", len(members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupScopeValidation(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
_, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com")
|
||||
|
||||
// Team-scoped without team_id → 400
|
||||
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Bad Group",
|
||||
"scope": "team",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("team scope without team_id: want 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Global with team_id → 400
|
||||
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Bad Group",
|
||||
"scope": "global",
|
||||
"team_id": "00000000-0000-0000-0000-000000000001",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("global scope with team_id: want 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGrants(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
_, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com")
|
||||
|
||||
// Create a group
|
||||
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Grantees",
|
||||
"scope": "global",
|
||||
})
|
||||
var group models.Group
|
||||
decode(w, &group)
|
||||
|
||||
// Create a persona (admin)
|
||||
w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
"name": "Test Bot",
|
||||
"base_model_id": "test-model",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var persona map[string]interface{}
|
||||
decode(w, &persona)
|
||||
personaID := persona["id"].(string)
|
||||
|
||||
// ── Get grant (no grant yet → default team_only) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get grant: want 200, got %d", w.Code)
|
||||
}
|
||||
var grantResp map[string]interface{}
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "team_only" {
|
||||
t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
|
||||
// ── Set grant: groups scope ──
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
||||
"grant_scope": "groups",
|
||||
"granted_groups": []string{group.ID},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Get grant (now groups) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "groups" {
|
||||
t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
|
||||
// ── Set grant: global scope ──
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
||||
"grant_scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set global grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Revoke: set back to team_only (deletes grant row) ──
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
||||
"grant_scope": "team_only",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Validation: groups scope without groups → 400 ──
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
||||
"grant_scope": "groups",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("groups without list: want 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
// Setup: admin + regular user (not on any team)
|
||||
adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
|
||||
userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
userToken := makeToken(userID, "gpuser@test.com", "user")
|
||||
|
||||
// Create a team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
|
||||
"name": "Secret Team",
|
||||
})
|
||||
var teamResp map[string]interface{}
|
||||
decode(w, &teamResp)
|
||||
teamID := teamResp["id"].(string)
|
||||
|
||||
// Create persona scoped to that team (user shouldn't see it without group access)
|
||||
var personaID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
|
||||
VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
|
||||
RETURNING id
|
||||
`, teamID, adminID).Scan(&personaID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert persona: %v", err)
|
||||
}
|
||||
if personaID == "" {
|
||||
t.Fatal("personaID is empty after insert")
|
||||
}
|
||||
|
||||
// ── User should NOT see team persona ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
var presetsResp map[string]interface{}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ := presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see team persona without group access")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create group + add user + grant persona ──
|
||||
w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
|
||||
"name": "Secret Readers",
|
||||
"scope": "global",
|
||||
})
|
||||
var group models.Group
|
||||
decode(w, &group)
|
||||
|
||||
w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
|
||||
"grant_scope": "groups",
|
||||
"granted_groups": []string{group.ID},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Diagnostic: verify grant row exists
|
||||
var grantCount int
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
|
||||
if grantCount == 0 {
|
||||
t.Fatal("DIAG: resource_grant row missing after SET")
|
||||
}
|
||||
|
||||
// Diagnostic: verify group membership
|
||||
var memberCount int
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
|
||||
if memberCount == 0 {
|
||||
t.Fatal("DIAG: group_members row missing")
|
||||
}
|
||||
|
||||
// Diagnostic: verify persona exists
|
||||
var personaExists bool
|
||||
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
|
||||
if !personaExists {
|
||||
t.Fatal("DIAG: persona not found or not active")
|
||||
}
|
||||
|
||||
// Diagnostic: run the subquery directly
|
||||
var subqCount int
|
||||
database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM resource_grants rg
|
||||
WHERE rg.resource_type = 'persona'
|
||||
AND (
|
||||
rg.grant_scope = 'global'
|
||||
OR (rg.grant_scope = 'groups'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM group_members gm
|
||||
WHERE gm.user_id = $1
|
||||
AND gm.group_id = ANY(rg.granted_groups)
|
||||
))
|
||||
)
|
||||
`, userID).Scan(&subqCount)
|
||||
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
|
||||
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
|
||||
|
||||
// ── User should NOW see team persona via group grant ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
found := false
|
||||
for _, p := range presets {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
|
||||
}
|
||||
|
||||
// ── Remove user from group → should lose access ──
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
||||
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see persona after group removal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user