From f5171d3bd3564a929dff7724ab3659090991b098 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Thu, 12 Mar 2026 10:22:08 +0000 Subject: [PATCH] Changeset 0.28.0.3 (#175) --- docs/ICD/README.md | 58 +- docs/ICD/teams.md | 153 ++++-- server/handlers/admin.go | 2 + server/handlers/groups.go | 26 +- server/handlers/integration_test.go | 618 +++++++++++++++++++++- server/handlers/roles.go | 2 +- server/handlers/team_providers.go | 27 +- server/handlers/teams.go | 223 +++----- server/handlers/workflow_instance_test.go | 2 +- server/main.go | 4 +- server/models/models.go | 1 + server/store/interfaces.go | 7 + server/store/postgres/groups.go | 3 +- server/store/postgres/team.go | 30 ++ server/store/sqlite/groups.go | 2 +- server/store/sqlite/team.go | 30 ++ src/js/ui-settings.js | 2 +- 17 files changed, 956 insertions(+), 234 deletions(-) diff --git a/docs/ICD/README.md b/docs/ICD/README.md index 645a917..e81f173 100644 --- a/docs/ICD/README.md +++ b/docs/ICD/README.md @@ -101,22 +101,70 @@ Standard HTTP status codes: 400 (bad request), 401 (not authenticated), 403 (not authorized), 404 (not found), 409 (conflict), 500 (server error), 502 (upstream provider failure). -### Pagination Envelope +### Response Envelopes -Endpoints that paginate return: +Three patterns. Every endpoint uses exactly one. + +**List (returns an array)** → always wrap in `{"data": []}`: + +```json +{ + "data": [...] +} +``` + +If paginated, pagination fields sit alongside `data`: ```json { "data": [...], "page": 1, "per_page": 50, - "total": 142, - "total_pages": 3 + "total": 142 } ``` Query params: `?page=1&per_page=50`. Not all list endpoints paginate — -some return `{ "data": [...] }` or a bare array in a named key. +unpaginated lists still use `{"data": [...]}`. + +This is a hard rule: `data` is the only array wrapper key. No +domain-specific keys (`teams`, `members`, `configs`, etc.) for arrays. +Clients parse every list response identically. + +**Single object (GET by ID, profile, health)** → return the object directly: + +```json +{ + "id": "uuid", + "name": "...", + ... +} +``` + +No wrapping. The response *is* the resource. + +**Composite (multiple named values, not an array)** → named keys: + +```json +{ + "active": 5, + "pending": 2 +} +``` + +Used for stats, counts, status endpoints — anything returning +multiple named scalars or heterogeneous data. The key names are +domain-specific and documented per endpoint. + +**Empty arrays** must serialize as `[]`, never `null`. Go handlers +must guard nil slices before serialization: + +```go +if result == nil { + result = []MyType{} +} +c.JSON(http.StatusOK, gin.H{"data": result}) +``` ### ID Format diff --git a/docs/ICD/teams.md b/docs/ICD/teams.md index 6e32e4e..bf12ed4 100644 --- a/docs/ICD/teams.md +++ b/docs/ICD/teams.md @@ -3,35 +3,58 @@ ### My Teams ``` -GET /teams/mine → { "teams": [...] } +GET /teams/mine → { "data": [...] } ``` -Returns teams the current user is a member of. +**Auth:** Authenticated user. + +Returns teams the current user is a member of (active teams only). + +Each team object: + +```json +{ + "id": "uuid", + "name": "Engineering", + "description": "...", + "is_active": true, + "settings": "{}", + "my_role": "admin|member", + "member_count": 5 +} +``` ### Team Administration -Team-admin-scoped routes (require `RequireTeamAdmin` middleware): - **Team CRUD (platform admin):** ``` -GET /admin/teams → { "teams": [...] } -POST /admin/teams -GET /admin/teams/:id -PUT /admin/teams/:id +GET /admin/teams → { "data": [...], "total": N, "page": N, "per_page": N } +POST /admin/teams ← { "name", "description" } → { "id", "name" } +GET /admin/teams/:id → team object (unwrapped) +PUT /admin/teams/:id ← { "name"?, "description"?, "is_active"?, "settings"? } DELETE /admin/teams/:id ``` -**Members:** +**Auth:** `RequireAdmin` (platform admin). + +`POST` returns `201`. `PUT`/`DELETE` return `{"ok": true}`. `PUT` with +no fields returns `400`. Duplicate name returns `409`. + +**Members (admin routes):** ``` -GET /admin/teams/:id/members → { "members": [...] } -POST /admin/teams/:id/members ← { "user_id", "role" } -PUT /admin/teams/:id/members/:memberId ← { "role" } +GET /admin/teams/:id/members → { "data": [...] } +POST /admin/teams/:id/members ← { "user_id", "role" } → { "id" } +PUT /admin/teams/:id/members/:memberId ← { "role" } DELETE /admin/teams/:id/members/:memberId ``` -**Team-scoped routes** (team admin, not platform admin): +**Auth:** `RequireAdmin`. + +`role` must be `admin` or `member`. Duplicate membership returns `409`. + +**Team-scoped routes** (team admin self-service): ``` GET /teams/:teamId/members @@ -40,47 +63,88 @@ PUT /teams/:teamId/members/:memberId DELETE /teams/:teamId/members/:memberId ``` +**Auth:** `RequireTeamAdmin`. + +Same handlers as admin routes; the `getTeamID()` helper reads either +`:id` or `:teamId`. + **Team Providers:** ``` -GET /teams/:teamId/providers → { "configs": [...] } -POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... } +GET /teams/:teamId/providers → { "data": [...], "allow_team_providers": bool } +POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... } PUT /teams/:teamId/providers/:id DELETE /teams/:teamId/providers/:id -GET /teams/:teamId/providers/:id/models → { "models": [...] } +GET /teams/:teamId/providers/:id/models → { "models": [...], "provider": "name" } ``` +**Auth:** `RequireTeamAdmin`. + +`GET` returns provider configs scoped to the team. The `models` endpoint +performs a live query to the upstream provider. + **Team Models:** ``` GET /teams/:teamId/models → { "models": [...] } ``` -Available models for this team (global + team-scoped). +**Auth:** `RequireTeamAdmin`. -**Team Personas:** See §4.2. +Returns all models available to this team: global admin models with +`visibility IN ('enabled', 'team')` plus live-queried team provider +models with `source` field (`"global"` or `"team"`). + +**Team Groups:** + +``` +GET /teams/:teamId/groups → { "data": [...] } +``` + +**Auth:** `RequireTeamAdmin`. + +Returns groups scoped to this team. + +**Team Personas:** See personas.md §Team Personas. **Team Roles:** ``` -GET /teams/:teamId/roles → { "roles": [...] } -PUT /teams/:teamId/roles/:role ← { "permissions": {...} } +GET /teams/:teamId/roles → { "data": {...} } +PUT /teams/:teamId/roles/:role ← RoleConfig object DELETE /teams/:teamId/roles/:role ``` +**Auth:** `RequireTeamAdmin`. + +`GET` returns the team's `model_roles` settings map (composite object, +not an array). Initially empty `{}`. Each key is a role name, value is +a `RoleConfig`. `PUT` validates the role name. `DELETE` removes the +override, falling back to the global role definition. + **Team Audit:** ``` -GET /teams/:teamId/audit → paginated audit log -GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types) +GET /teams/:teamId/audit → { "data": [...], "total": N, "page": N, "per_page": N } +GET /teams/:teamId/audit/actions → { "actions": [...] } ``` +**Auth:** `RequireTeamAdmin`. + +Audit log is scoped to actions performed by team members. Supports +query filters: `?action=`, `?actor_id=`, `?resource_type=`. + **Team Usage:** ``` GET /teams/:teamId/usage → { "totals": {...}, "results": [...] } ``` +**Auth:** `RequireTeamAdmin`. + +Returns usage against team-owned providers. Supports query params for +date range filtering. + ### Groups & Resource Grants Groups are ACL containers that decouple access from team membership. @@ -90,45 +154,58 @@ scope. **My Groups:** ``` -GET /groups/mine → { "groups": [...] } +GET /groups/mine → { "data": [...] } ``` +**Auth:** Authenticated user. + **Admin Group CRUD:** ``` -GET /admin/groups → { "groups": [...] } -POST /admin/groups ← { "name", "scope", "team_id" } -GET /admin/groups/:id -PUT /admin/groups/:id +GET /admin/groups → { "data": [...] } +POST /admin/groups ← { "name", "scope", "team_id"? } +GET /admin/groups/:id → group object (unwrapped) +PUT /admin/groups/:id ← { "name"?, "description"?, "permissions"?, ... } DELETE /admin/groups/:id ``` +**Auth:** `RequireAdmin`. + +`POST` returns `201`. Duplicate name returns `409`. Deleting a +system-sourced group (e.g. Everyone) returns `400`. + **Group Members:** ``` -GET /admin/groups/:id/members → { "members": [...] } +GET /admin/groups/:id/members → { "data": [...] } POST /admin/groups/:id/members ← { "user_id" } DELETE /admin/groups/:id/members/:userId ``` +**Auth:** `RequireAdmin`. + **Resource Grants:** ``` -GET /admin/grants/:type/:id → { "grant": {...} } -PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] } +GET /admin/grants/:type/:id → grant object (unwrapped) +PUT /admin/grants/:type/:id ← { "grant_scope", "granted_groups"?: [...] } DELETE /admin/grants/:type/:id ``` +**Auth:** `RequireAdmin`. + `:type` is `persona` or `kb`. `:id` is the resource ID. -Grant types: +Grant scopes: -| `grant_type` | Visibility | -|--------------|-----------| +| `grant_scope` | Visibility | +|---------------|-----------| | `team_only` | Only the owning team | | `global` | All authenticated users | | `groups` | Members of specified groups | +Setting `grant_scope: "groups"` without `granted_groups` returns `400`. + ### Permissions Groups carry fine-grained permissions. Permission resolution: @@ -140,13 +217,18 @@ union of all group permissions + Everyone group permissions. GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] } ``` +**Auth:** `RequireAdmin`. + **Get user's effective permissions:** ``` -GET /admin/users/:id/permissions → { "permissions": [...], "groups": [...] } +GET /admin/users/:id/permissions → { "permissions": [...], "user_id": "...", "groups": [...] } ``` -Returns the resolved permission set and contributing groups. +**Auth:** `RequireAdmin`. + +Returns the resolved permission set and contributing group IDs +(always includes the Everyone group). **Group permission fields** (set via `PUT /admin/groups/:id`): @@ -174,4 +256,3 @@ row needed). Editable by admins, cannot be deleted (`source=system`). resolved permission set. Returns 403 if the required permission is not present. See [enums.md](enums.md) for the full permission constant list. - diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 8d9feb0..3200e1f 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -82,6 +83,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { PasswordHash: string(hash), Role: role, IsActive: true, + Handle: auth.UniqueHandle(c.Request.Context(), h.stores.Users, models.HandleFromName(req.Username)), } if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { diff --git a/server/handlers/groups.go b/server/handlers/groups.go index 316172e..27d5404 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -2,6 +2,7 @@ package handlers import ( "database/sql" + "errors" "net/http" "github.com/gin-gonic/gin" @@ -191,16 +192,15 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) { id := c.Param("id") err := h.stores.Groups.Delete(c.Request.Context(), id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } + if errors.Is(err, store.ErrSystemGroup) { + c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"}) + return + } if err != nil { - // Store layer returns a plain error for system groups. - if err.Error() == "system groups cannot be deleted" { - c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"}) - return - } c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } @@ -428,8 +428,9 @@ func (h *GroupHandler) ListPermissions(c *gin.Context) { // GET /api/v1/admin/users/:id/permissions func (h *GroupHandler) GetUserPermissions(c *gin.Context) { userID := c.Param("id") + ctx := c.Request.Context() - perms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID) + perms, err := auth.ResolvePermissions(ctx, h.stores, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) return @@ -440,5 +441,14 @@ func (h *GroupHandler) GetUserPermissions(c *gin.Context) { for p := range perms { list = append(list, p) } - c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID}) + + // Contributing groups: explicit membership + Everyone + groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID) + if groupIDs == nil { + groupIDs = []string{} + } + // Everyone group always contributes + groupIDs = append(groupIDs, auth.EveryoneGroupID) + + c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID, "groups": groupIDs}) } diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 8bff3ae..085a112 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -170,18 +170,33 @@ func setupHarness(t *testing.T) *testHarness { protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) // Team self-service (same route group as production) - teams := NewTeamHandler(nil) + teams := NewTeamHandler(stores, nil) protected.GET("/teams/mine", teams.MyTeams) teamScoped := protected.Group("/teams/:teamId") teamScoped.Use(middleware.RequireTeamAdmin()) { + // Team members (team admin self-service) + teamScoped.GET("/members", teams.ListMembers) + teamScoped.POST("/members", teams.AddMember) + teamScoped.PUT("/members/:memberId", teams.UpdateMember) + teamScoped.DELETE("/members/:memberId", teams.RemoveMember) + + // Team providers teamScoped.GET("/providers", teams.ListTeamProviders) teamScoped.POST("/providers", teams.CreateTeamProvider) teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + // Team audit + teamScoped.GET("/audit", teams.ListTeamAuditLog) + teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) + + // Team groups + teamGroupH := NewGroupHandler(stores) + teamScoped.GET("/groups", teamGroupH.ListTeamGroups) + // Team usage teamUsage := NewUsageHandler(stores) teamScoped.GET("/usage", teamUsage.TeamUsage) @@ -317,8 +332,12 @@ func setupHarness(t *testing.T) *testHarness { admin.GET("/teams", teams.ListTeams) admin.POST("/teams", teams.CreateTeam) admin.GET("/teams/:id", teams.GetTeam) + admin.PUT("/teams/:id", teams.UpdateTeam) + admin.DELETE("/teams/:id", teams.DeleteTeam) admin.GET("/teams/:id/members", teams.ListMembers) admin.POST("/teams/:id/members", teams.AddMember) + admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember) + admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember) admin.GET("/personas", personas.ListAdminPersonas) admin.POST("/personas", personas.CreateAdminPersona) admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 @@ -340,6 +359,10 @@ func setupHarness(t *testing.T) *testHarness { admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) + // Admin permissions (v0.16.0) + admin.GET("/permissions", groupAdm.ListPermissions) + admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions) + // User groups (v0.16.0) protected.GET("/groups/mine", groupAdm.MyGroups) @@ -1906,10 +1929,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String()) } - var roleList map[string]interface{} - decode(w, &roleList) - if len(roleList) != 0 { - t.Fatalf("team roles should be empty initially, got %d", len(roleList)) + var roleResp map[string]interface{} + decode(w, &roleResp) + roleData, _ := roleResp["data"].(map[string]interface{}) + if roleData == nil { + roleData = map[string]interface{}{} + } + if len(roleData) != 0 { + t.Fatalf("team roles should be empty initially, got %d", len(roleData)) } // Set team role override @@ -1926,8 +1953,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { // List should now have override w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) - decode(w, &roleList) - if _, ok := roleList["utility"]; !ok { + roleResp = map[string]interface{}{} + decode(w, &roleResp) + roleData, _ = roleResp["data"].(map[string]interface{}) + if roleData == nil || len(roleData) == 0 { + t.Fatal("team roles should have utility after update") + } + if _, ok := roleData["utility"]; !ok { t.Fatal("team roles should have utility after update") } @@ -1939,10 +1971,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) { // List should be empty again w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) - roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps - decode(w, &roleList) - if _, ok := roleList["utility"]; ok { - t.Fatal("team roles should not have utility after delete") + roleResp = map[string]interface{}{} + decode(w, &roleResp) + roleData, _ = roleResp["data"].(map[string]interface{}) + if roleData != nil { + if _, ok := roleData["utility"]; ok { + t.Fatal("team roles should not have utility after delete") + } } } @@ -3383,3 +3418,564 @@ func TestIntegration_ChannelListWithTypeFilter(t *testing.T) { // Verify the request succeeded (200) — that's the regression test. // Channel content verification is covered by other tests. } + +// ═══════════════════════════════════════════ +// TEAMS ICD AUDIT — Phase 2 Tests +// ═══════════════════════════════════════════ + +// ── Admin Team CRUD Lifecycle ────────────── + +func TestAudit_AdminTeamCRUD(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + // ── Create ── + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "Audit Team", "description": "For audit testing", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String()) + } + var created map[string]interface{} + decode(w, &created) + teamID := created["id"].(string) + if created["name"] != "Audit Team" { + t.Fatalf("create response should have name, got %v", created["name"]) + } + + // ── List (data envelope) ── + w = h.request("GET", "/api/v1/admin/teams", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list teams: want 200, got %d", w.Code) + } + var listResp map[string]interface{} + decode(w, &listResp) + if _, ok := listResp["data"]; !ok { + t.Fatal("GET /admin/teams must return 'data' key (envelope convention)") + } + if _, ok := listResp["total"]; !ok { + t.Fatal("GET /admin/teams must return 'total' key (paginated)") + } + + // ── Get ── + w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String()) + } + var got map[string]interface{} + decode(w, &got) + if got["name"] != "Audit Team" { + t.Fatalf("get team name mismatch: %v", got["name"]) + } + + // ── Get nonexistent ── + w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("get nonexistent team: want 404, got %d", w.Code) + } + + // ── Update ── + w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{ + "name": "Renamed Team", + }) + if w.Code != http.StatusOK { + t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify update + w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil) + decode(w, &got) + if got["name"] != "Renamed Team" { + t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"]) + } + + // ── Update with no fields → 400 ── + w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{}) + if w.Code != http.StatusBadRequest { + t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String()) + } + + // ── Duplicate name → 409 ── + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "Second Team", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create second team: %d: %s", w.Code, w.Body.String()) + } + w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{ + "name": "Second Team", + }) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String()) + } + + // ── Delete ── + w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify deleted + w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("deleted team: want 404, got %d", w.Code) + } + + // ── Delete nonexistent → 404 ── + w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("delete nonexistent: want 404, got %d", w.Code) + } +} + +// ── Admin Member Update + Remove ─────────── + +func TestAudit_AdminMemberUpdateRemove(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + userID := database.SeedTestUser(t, "bob", "bob@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + + // Create team + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "MemberTest", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + // Add member + w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, + map[string]string{"user_id": userID, "role": "member"}) + if w.Code != http.StatusCreated { + t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String()) + } + + // Get member ID from listing + w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil) + var membersResp map[string]interface{} + decode(w, &membersResp) + members := membersResp["data"].([]interface{}) + if len(members) != 1 { + t.Fatalf("expected 1 member, got %d", len(members)) + } + memberID := members[0].(map[string]interface{})["id"].(string) + + // Update role → admin + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, + map[string]string{"role": "admin"}) + if w.Code != http.StatusOK { + t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Update nonexistent member → 404 + w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken, + map[string]string{"role": "member"}) + if w.Code != http.StatusNotFound { + t.Fatalf("update nonexistent member: want 404, got %d", w.Code) + } + + // Remove member + w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Remove nonexistent → 404 + w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("remove nonexistent: want 404, got %d", w.Code) + } +} + +// ── C1: Cross-Team Member Mutation ───────── +// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint. +// A team admin for Team A can mutate membership in Team B by knowing +// the team_members.id primary key. + +func TestAudit_CrossTeamMemberMutation(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + // Create two users + aliceID := database.SeedTestUser(t, "alice", "alice@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID) + bobID := database.SeedTestUser(t, "bob", "bob@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID) + + // Create Team A and Team B + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"}) + var tA map[string]interface{} + decode(w, &tA) + teamAID := tA["id"].(string) + + w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"}) + var tB map[string]interface{} + decode(w, &tB) + teamBID := tB["id"].(string) + + // Alice is admin of Team A + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken, + map[string]string{"user_id": aliceID, "role": "admin"}) + + // Bob is member of Team B + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, + map[string]string{"user_id": bobID, "role": "member"}) + + // Get Bob's team_members.id from Team B listing + w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil) + var bMembers map[string]interface{} + decode(w, &bMembers) + bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string) + + // Alice (Team A admin) tries to update Bob's role in Team B + // via the team-scoped route for Team A — should fail with 404 because + // bobMemberID does not belong to Team A. + aliceToken := makeToken(aliceID, "alice@test.com", "user") + w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, + map[string]string{"role": "admin"}) + if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden { + t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+ + "UpdateMember WHERE clause has no team_id constraint — authorization bypass", + w.Code, w.Body.String()) + } + + // Alice (Team A admin) tries to remove Bob from Team B + w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil) + if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden { + t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+ + "RemoveMember WHERE clause has no team_id constraint — authorization bypass", + w.Code, w.Body.String()) + } +} + +// ── Team Audit Log ───────────────────────── + +func TestAudit_TeamAuditLog(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + // Create team + add admin as team member so audit actions are scoped + teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) + teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user") + + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "AuditTeam", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, + map[string]string{"user_id": teamAdminID, "role": "admin"}) + + // ── Query audit log (team admin) ── + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String()) + } + var auditResp map[string]interface{} + decode(w, &auditResp) + // Must use "data" envelope + if _, ok := auditResp["data"]; !ok { + t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)") + } + if _, ok := auditResp["total"]; !ok { + t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)") + } + + // ── Query audit actions ── + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String()) + } + var actionsResp map[string]interface{} + decode(w, &actionsResp) + if _, ok := actionsResp["actions"]; !ok { + t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key") + } +} + +// ── Admin Permissions Listing ────────────── + +func TestAudit_AdminPermissions(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + // ── List all permissions ── + w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String()) + } + var permResp map[string]interface{} + decode(w, &permResp) + perms, ok := permResp["permissions"] + if !ok { + t.Fatal("GET /admin/permissions must return 'permissions' key") + } + permList := perms.([]interface{}) + if len(permList) == 0 { + t.Fatal("permissions list should not be empty") + } + + // Verify known permissions are present + found := false + for _, p := range permList { + if p.(string) == "model.use" { + found = true + break + } + } + if !found { + t.Fatal("permissions list should contain 'model.use'") + } +} + +// ── User Effective Permissions ───────────── + +func TestAudit_UserEffectivePermissions(t *testing.T) { + h := setupHarness(t) + adminID, adminToken := h.createAdminUser("admin", "admin@test.com") + + userID := database.SeedTestUser(t, "charlie", "charlie@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + + // ── Get permissions for user ── + w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + if _, ok := resp["permissions"]; !ok { + t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key") + } + if _, ok := resp["user_id"]; !ok { + t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key") + } + // H10: must include contributing groups + groups, ok := resp["groups"] + if !ok { + t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)") + } + groupList := groups.([]interface{}) + // Should always contain the Everyone group + found := false + for _, g := range groupList { + if g.(string) == "00000000-0000-0000-0000-000000000001" { + found = true + break + } + } + if !found { + t.Fatalf("groups should always include the Everyone group, got %v", groupList) + } + + // ── Get permissions for admin (should include their own set) ── + w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get admin permissions: want 200, got %d", w.Code) + } +} + +// ── System Group Deletion Rejection ──────── + +func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + everyoneID := "00000000-0000-0000-0000-000000000001" + + // Seed the Everyone group (TruncateAll wipes migration-seeded data) + _, err := database.TestDB.Exec(dialectSQL(` + INSERT INTO groups (id, name, description, scope, created_by, source, permissions) + VALUES ($1, $2, $3, $4, NULL, $5, $6) + `), everyoneID, "Everyone", + "Implicit group — all authenticated users receive these permissions.", + "global", "system", `["model.use","kb.read","channel.create"]`) + if err != nil { + t.Fatalf("seed Everyone group: %v", err) + } + + // Attempt to delete the Everyone system group → should fail with 400 + w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("delete system group: want 400, got %d: %s\n"+ + "System groups (source=system) must be protected from deletion", + w.Code, w.Body.String()) + } +} + +// ── Resource Grant DELETE Round-Trip ──────── + +func TestAudit_ResourceGrantDelete(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + // Create a persona to grant + w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{ + "name": "Grant Test Persona", + "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) + + // Set a grant + 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 grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the grant exists + 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"] != "global" { + t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"]) + } + + // DELETE the grant + w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // After delete, GET should return 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 after delete: want 200, got %d", w.Code) + } + decode(w, &grantResp) + if grantResp["grant_scope"] != "team_only" { + t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"]) + } +} + +// ── H8: Team Providers Envelope ──────────── +// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}. + +func TestAudit_TeamProvidersEnvelope(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) + teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user") + + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "ProvEnvTeam", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, + map[string]string{"user_id": teamAdminID, "role": "admin"}) + + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + + // Must use "data" not "providers" + if _, ok := resp["data"]; !ok { + t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+ + "got keys: %v", mapKeys(resp)) + } + if _, ok := resp["allow_team_providers"]; !ok { + t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'") + } +} + +// ── H9: Team Roles Envelope ──────────────── +// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}. + +func TestAudit_TeamRolesEnvelope(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) + teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user") + + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "RolesEnvTeam", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, + map[string]string{"user_id": teamAdminID, "role": "admin"}) + + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + + // Should be wrapped in {"data": {...}} per composite convention + if _, ok := resp["data"]; !ok { + t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+ + "got keys: %v — bare map returned without wrapper", mapKeys(resp)) + } +} + +// ── Team Groups Listing ──────────────────── + +func TestAudit_TeamGroupsListing(t *testing.T) { + h := setupHarness(t) + _, adminToken := h.createAdminUser("admin", "admin@test.com") + + teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID) + teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user") + + w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{ + "name": "GroupsTeam", + }) + var team map[string]interface{} + decode(w, &team) + teamID := team["id"].(string) + + h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, + map[string]string{"user_id": teamAdminID, "role": "admin"}) + + w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + + // Must use "data" envelope + if _, ok := resp["data"]; !ok { + t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp)) + } +} + +// ── Helper: map keys for diagnostic output ── + +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/server/handlers/roles.go b/server/handlers/roles.go index 1f53ee6..37df14e 100644 --- a/server/handlers/roles.go +++ b/server/handlers/roles.go @@ -161,7 +161,7 @@ func (h *RolesHandler) ListTeamRoles(c *gin.Context) { } } - c.JSON(http.StatusOK, roleOverrides) + c.JSON(http.StatusOK, gin.H{"data": roleOverrides}) } // UpdateTeamRole sets a team role override. diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index a53e3ef..9c7dc20 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -61,7 +61,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "providers": configs, + "data": configs, "allow_team_providers": isTeamProvidersAllowed(teamID), }) } @@ -82,6 +82,7 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { APIKey string `json:"api_key"` ModelDefault string `json:"model_default,omitempty"` Config map[string]interface{} `json:"config,omitempty"` + Headers map[string]string `json:"headers,omitempty"` IsPrivate bool `json:"is_private,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -103,6 +104,12 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { configJSON = string(b) } + headersJSON := "{}" + if req.Headers != nil { + b, _ := json.Marshal(req.Headers) + headersJSON = string(b) + } + // Encrypt the API key for team scope var apiKeyEnc, keyNonce []byte if req.APIKey != "" { @@ -120,11 +127,11 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { id, err := database.InsertReturningID(` INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, - api_key_enc, key_nonce, key_scope, model_default, config, is_private) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10) + api_key_enc, key_nonce, key_scope, model_default, config, is_private, headers) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb) RETURNING id `, "team", teamID, req.Name, req.Provider, req.Endpoint, - apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, + apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, headersJSON, ) if err != nil { log.Printf("[WARN] Failed to create team provider: %v", err) @@ -146,6 +153,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { APIKey *string `json:"api_key,omitempty"` ModelDefault *string `json:"model_default,omitempty"` Config map[string]interface{} `json:"config,omitempty"` + Headers map[string]string `json:"headers,omitempty"` IsActive *bool `json:"is_active,omitempty"` IsPrivate *bool `json:"is_private,omitempty"` } @@ -165,10 +173,12 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { // Build dynamic update using ? placeholders (works on both dialects) setClauses := []string{"updated_at = " + database.Q("NOW()")} args := []interface{}{} + fieldCount := 0 addSet := func(col string, val interface{}) { setClauses = append(setClauses, col+" = ?") args = append(args, val) + fieldCount++ } if req.Name != nil { @@ -203,6 +213,15 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { b, _ := json.Marshal(req.Config) addSet("config", string(b)) } + if req.Headers != nil { + b, _ := json.Marshal(req.Headers) + addSet("headers", string(b)) + } + + if fieldCount == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } args = append(args, providerID, teamID) diff --git a/server/handlers/teams.go b/server/handlers/teams.go index d66ce97..c4fdf62 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -14,7 +14,9 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Request types ─────────────────────────── @@ -43,11 +45,12 @@ type updateMemberRequest struct { // ── Handler ───────────────────────────────── type TeamHandler struct{ - vault *crypto.KeyResolver + stores store.Stores + vault *crypto.KeyResolver } -func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler { - return &TeamHandler{vault: vault} +func NewTeamHandler(s store.Stores, vault *crypto.KeyResolver) *TeamHandler { + return &TeamHandler{stores: s, vault: vault} } // ── Admin: List All Teams ─────────────────── @@ -61,59 +64,29 @@ func (h *TeamHandler) ListTeams(c *gin.Context) { if perPage < 1 || perPage > 100 { perPage = 50 } - offset := (page - 1) * perPage - var total int - if err := database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM teams`)).Scan(&total); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count teams"}) - return - } - - rows, err := database.DB.Query(database.Q(` - SELECT t.id, t.name, t.description, t.created_by, t.is_active, - COALESCE(t.settings, '{}'), t.created_at, t.updated_at, - COALESCE(mc.cnt, 0) AS member_count - FROM teams t - LEFT JOIN ( - SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id - ) mc ON mc.team_id = t.id - ORDER BY t.name ASC - LIMIT $1 OFFSET $2 - `), perPage, offset) + all, err := h.stores.Teams.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - defer rows.Close() - - var teams []gin.H - for rows.Next() { - var id, name, desc, createdBy, settings string - var isActive bool - var memberCount int - var createdAt, updatedAt time.Time - if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings, - database.ST(&createdAt), database.ST(&updatedAt), &memberCount); err != nil { - continue - } - teams = append(teams, gin.H{ - "id": id, - "name": name, - "description": desc, - "created_by": createdBy, - "is_active": isActive, - "settings": settings, - "member_count": memberCount, - "created_at": createdAt, - "updated_at": updatedAt, - }) + if all == nil { + all = []models.Team{} } - if teams == nil { - teams = []gin.H{} + total := len(all) + + // In-memory pagination (teams are low-cardinality) + offset := (page - 1) * perPage + end := offset + perPage + if offset > total { + offset = total + } + if end > total { + end = total } c.JSON(http.StatusOK, gin.H{ - "data": teams, + "data": all[offset:end], "total": total, "page": page, "per_page": perPage, @@ -131,12 +104,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { return } - id, err := database.InsertReturningID(` - INSERT INTO teams (name, description, created_by) - VALUES ($1, $2, $3) - RETURNING id - `, req.Name, req.Description, adminID) - if err != nil { + team := &models.Team{ + Name: req.Name, + Description: req.Description, + CreatedBy: adminID, + IsActive: true, + } + if err := h.stores.Teams.Create(c.Request.Context(), team); err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) return @@ -145,8 +119,8 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { return } - c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name}) - AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name}) + c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name}) + AuditLog(c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name}) } // ── Admin: Get Team ───────────────────────── @@ -154,18 +128,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { func (h *TeamHandler) GetTeam(c *gin.Context) { teamID := c.Param("id") - var name, desc, createdBy, settings string - var isActive bool - var memberCount int - var createdAt, updatedAt time.Time - - err := database.DB.QueryRow(database.Q(` - SELECT t.name, t.description, t.created_by, t.is_active, - COALESCE(t.settings, '{}'), t.created_at, t.updated_at, - (SELECT COUNT(*) FROM team_members WHERE team_id = t.id) - FROM teams t WHERE t.id = $1 - `), teamID).Scan(&name, &desc, &createdBy, &isActive, &settings, - database.ST(&createdAt), database.ST(&updatedAt), &memberCount) + team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) return @@ -175,17 +138,7 @@ func (h *TeamHandler) GetTeam(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "id": teamID, - "name": name, - "description": desc, - "created_by": createdBy, - "is_active": isActive, - "settings": settings, - "member_count": memberCount, - "created_at": createdAt, - "updated_at": updatedAt, - }) + c.JSON(http.StatusOK, team) } // ── Admin: Update Team ────────────────────── @@ -271,14 +224,20 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) { func (h *TeamHandler) DeleteTeam(c *gin.Context) { teamID := c.Param("id") + ctx := c.Request.Context() - res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID) - if err != nil { + // Verify existence + if _, err := h.stores.Teams.GetByID(ctx, teamID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } - if n, _ := res.RowsAffected(); n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) + + if err := h.stores.Teams.Delete(ctx, teamID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } @@ -291,38 +250,13 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) { func (h *TeamHandler) ListMembers(c *gin.Context) { teamID := getTeamID(c) - rows, err := database.DB.Query(database.Q(` - SELECT tm.id, tm.user_id, tm.role, tm.joined_at, - u.email, COALESCE(u.display_name, '') AS display_name, u.role AS user_role - FROM team_members tm - JOIN users u ON u.id = tm.user_id - WHERE tm.team_id = $1 - ORDER BY tm.role ASC, u.email ASC - `), teamID) + members, err := h.stores.Teams.ListMembers(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - defer rows.Close() - - var members []gin.H - for rows.Next() { - var id, userID, role, email, displayName, userRole, joinedAt string - if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil { - continue - } - members = append(members, gin.H{ - "id": id, - "user_id": userID, - "role": role, - "joined_at": joinedAt, - "email": email, - "display_name": displayName, - "user_role": userRole, - }) - } if members == nil { - members = []gin.H{} + members = []models.TeamMember{} } c.JSON(http.StatusOK, gin.H{"data": members}) @@ -377,6 +311,7 @@ func (h *TeamHandler) AddMember(c *gin.Context) { // ── Members: Update Role ──────────────────── func (h *TeamHandler) UpdateMember(c *gin.Context) { + teamID := getTeamID(c) memberID := c.Param("memberId") var req updateMemberRequest @@ -386,8 +321,8 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) { } res, err := database.DB.Exec(database.Q(` - UPDATE team_members SET role = $1 WHERE id = $2 - `), req.Role, memberID) + UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3 + `), req.Role, memberID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return @@ -406,9 +341,10 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) { // ── Members: Remove ───────────────────────── func (h *TeamHandler) RemoveMember(c *gin.Context) { + teamID := getTeamID(c) memberID := c.Param("memberId") - res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1`), memberID) + res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"}) return @@ -429,42 +365,13 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) { func (h *TeamHandler) MyTeams(c *gin.Context) { userID := getUserID(c) - rows, err := database.DB.Query(database.Q(` - SELECT t.id, t.name, t.description, t.is_active, - COALESCE(t.settings, '{}'), - tm.role AS my_role, - (SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count - FROM teams t - JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1 - WHERE t.is_active = true - ORDER BY t.name ASC - `), userID) + teams, err := h.stores.Teams.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - defer rows.Close() - - var teams []gin.H - for rows.Next() { - var id, name, desc, settings, myRole string - var isActive bool - var memberCount int - if err := rows.Scan(&id, &name, &desc, &isActive, &settings, &myRole, &memberCount); err != nil { - continue - } - teams = append(teams, gin.H{ - "id": id, - "name": name, - "description": desc, - "is_active": isActive, - "settings": settings, - "my_role": myRole, - "member_count": memberCount, - }) - } if teams == nil { - teams = []gin.H{} + teams = []models.Team{} } c.JSON(http.StatusOK, gin.H{"data": teams}) @@ -581,11 +488,6 @@ func getTeamID(c *gin.Context) string { return c.Param("id") } -// isUniqueViolation checks if a PG/SQLite error is a unique constraint violation. -func isUniqueViolation(err error) bool { - return database.IsUniqueViolation(err) -} - // IsTeamAdmin checks if a user is an admin of the given team. func IsTeamAdmin(userID, teamID string) bool { var role string @@ -706,11 +608,6 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { ORDER BY al.created_at DESC ` + limitOffset - if database.IsPostgres() { - // Re-convert placeholders for the full query - query = convertPlaceholders(query) - } - rows, err := database.DB.Query(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) @@ -719,15 +616,15 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { defer rows.Close() type entry struct { - ID string `json:"id"` - ActorID *string `json:"actor_id"` - ActorName *string `json:"actor_name"` - Action string `json:"action"` - ResourceType string `json:"resource_type"` - ResourceID *string `json:"resource_id"` - Metadata string `json:"metadata"` - IPAddress *string `json:"ip_address"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ActorID *string `json:"actor_id"` + ActorName *string `json:"actor_name"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID *string `json:"resource_id"` + Metadata string `json:"metadata"` + IPAddress *string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` } entries := make([]entry, 0) @@ -735,7 +632,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { var e entry var actorName sql.NullString if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, - &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil { + &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil { continue } if actorName.Valid { diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go index 34207b0..05370fd 100644 --- a/server/handlers/workflow_instance_test.go +++ b/server/handlers/workflow_instance_test.go @@ -84,7 +84,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) // Teams (admin routes for creating teams + adding members) - teamH := NewTeamHandler(nil) + teamH := NewTeamHandler(stores, nil) admin := api.Group("/admin") admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) admin.POST("/teams", teamH.CreateTeam) diff --git a/server/main.go b/server/main.go index 980ca0c..460350c 100644 --- a/server/main.go +++ b/server/main.go @@ -857,7 +857,7 @@ func main() { protected.GET("/memories/count", memH.MemoryCount) // Teams (user: my teams) - teams := handlers.NewTeamHandler(keyResolver) + teams := handlers.NewTeamHandler(stores, keyResolver) protected.GET("/teams/mine", teams.MyTeams) // Groups (user: my groups — v0.16.0) @@ -995,7 +995,7 @@ func main() { admin.POST("/memories/bulk-approve", adminMemH.BulkApprove) // Teams (admin) - teamAdm := handlers.NewTeamHandler(keyResolver) + teamAdm := handlers.NewTeamHandler(stores, keyResolver) admin.GET("/teams", teamAdm.ListTeams) admin.POST("/teams", teamAdm.CreateTeam) admin.GET("/teams/:id", teamAdm.GetTeam) diff --git a/server/models/models.go b/server/models/models.go index 4aa25b9..5c599a1 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -97,6 +97,7 @@ type Team struct { IsActive bool `json:"is_active" db:"is_active"` Settings JSONMap `json:"settings,omitempty" db:"settings"` MemberCount int `json:"member_count,omitempty"` // computed + MyRole string `json:"my_role,omitempty"` // computed, user context } type TeamMember struct { diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 86cb909..5ac2409 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -2,11 +2,17 @@ package store import ( "context" + "errors" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) +// ── Sentinel Errors ───────────────────────── + +// ErrSystemGroup is returned when attempting to delete a system-sourced group. +var ErrSystemGroup = errors.New("system groups cannot be deleted") + // ========================================= // STORES — Data Access Layer // ========================================= @@ -197,6 +203,7 @@ type TeamStore interface { Update(ctx context.Context, id string, fields map[string]interface{}) error Delete(ctx context.Context, id string) error List(ctx context.Context) ([]models.Team, error) + ListForUser(ctx context.Context, userID string) ([]models.Team, error) // active teams with MyRole // Members AddMember(ctx context.Context, teamID, userID, role string) error diff --git a/server/store/postgres/groups.go b/server/store/postgres/groups.go index fb0120a..210ec2b 100644 --- a/server/store/postgres/groups.go +++ b/server/store/postgres/groups.go @@ -8,6 +8,7 @@ import ( "fmt" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── GroupStore ────────────────────────────── @@ -118,7 +119,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error { return err } if source == "system" { - return fmt.Errorf("system groups cannot be deleted") + return store.ErrSystemGroup } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id) if err != nil { diff --git a/server/store/postgres/team.go b/server/store/postgres/team.go index f7faf49..01ca050 100644 --- a/server/store/postgres/team.go +++ b/server/store/postgres/team.go @@ -87,6 +87,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) { return result, rows.Err() } +func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT t.id, t.name, t.description, t.created_by, t.is_active, + COALESCE(t.settings, '{}'), t.created_at, t.updated_at, + tm.role AS my_role, + (SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count + FROM teams t + JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1 + WHERE t.is_active = true + ORDER BY t.name ASC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.Team + for rows.Next() { + var t models.Team + var settingsJSON []byte + err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive, + &settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MyRole, &t.MemberCount) + if err != nil { + return nil, err + } + json.Unmarshal(settingsJSON, &t.Settings) + result = append(result, t) + } + return result, rows.Err() +} + // ── Members ───────────────────────────────── func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error { diff --git a/server/store/sqlite/groups.go b/server/store/sqlite/groups.go index d93620b..eacfcd5 100644 --- a/server/store/sqlite/groups.go +++ b/server/store/sqlite/groups.go @@ -150,7 +150,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error { return err } if source == "system" { - return fmt.Errorf("system groups cannot be deleted") + return store.ErrSystemGroup } res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id) if err != nil { diff --git a/server/store/sqlite/team.go b/server/store/sqlite/team.go index 03ec66f..162ef5c 100644 --- a/server/store/sqlite/team.go +++ b/server/store/sqlite/team.go @@ -94,6 +94,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) { return result, rows.Err() } +func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT t.id, t.name, t.description, t.created_by, t.is_active, + COALESCE(t.settings, '{}'), t.created_at, t.updated_at, + tm.role AS my_role, + (SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count + FROM teams t + JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = ? + WHERE t.is_active = 1 + ORDER BY t.name ASC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.Team + for rows.Next() { + var t models.Team + var settingsJSON []byte + err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive, + &settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MyRole, &t.MemberCount) + if err != nil { + return nil, err + } + json.Unmarshal(settingsJSON, &t.Settings) + result = append(result, t) + } + return result, rows.Err() +} + // ── Members ───────────────────────────────── func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error { diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js index 19686dc..8be3003 100644 --- a/src/js/ui-settings.js +++ b/src/js/ui-settings.js @@ -466,7 +466,7 @@ Object.assign(UI, { // Handle policy check const allowed = resp.allow_team_providers !== false; if (addBtn) addBtn.style.display = allowed ? '' : 'none'; - if (!allowed && !(resp.providers || []).length) { + if (!allowed && !(resp.data || resp.providers || []).length) { throw { message: 'Team provider management is disabled by your administrator', _empty: true }; } return resp;