Changeset 0.28.0.3 (#175)

This commit is contained in:
2026-03-12 10:22:08 +00:00
parent 8e08f3e4b0
commit f5171d3bd3
17 changed files with 956 additions and 234 deletions

View File

@@ -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), 403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
502 (upstream provider failure). 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 ```json
{ {
"data": [...], "data": [...],
"page": 1, "page": 1,
"per_page": 50, "per_page": 50,
"total": 142, "total": 142
"total_pages": 3
} }
``` ```
Query params: `?page=1&per_page=50`. Not all list endpoints paginate — 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 ### ID Format

View File

@@ -3,35 +3,58 @@
### My Teams ### 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 Administration
Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
**Team CRUD (platform admin):** **Team CRUD (platform admin):**
``` ```
GET /admin/teams → { "teams": [...] } GET /admin/teams → { "data": [...], "total": N, "page": N, "per_page": N }
POST /admin/teams POST /admin/teams ← { "name", "description" } → { "id", "name" }
GET /admin/teams/:id GET /admin/teams/:id → team object (unwrapped)
PUT /admin/teams/:id PUT /admin/teams/:id ← { "name"?, "description"?, "is_active"?, "settings"? }
DELETE /admin/teams/:id 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": [...] } GET /admin/teams/:id/members → { "data": [...] }
POST /admin/teams/:id/members ← { "user_id", "role" } POST /admin/teams/:id/members ← { "user_id", "role" } → { "id" }
PUT /admin/teams/:id/members/:memberId ← { "role" } PUT /admin/teams/:id/members/:memberId ← { "role" }
DELETE /admin/teams/:id/members/:memberId 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 GET /teams/:teamId/members
@@ -40,47 +63,88 @@ PUT /teams/:teamId/members/:memberId
DELETE /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:** **Team Providers:**
``` ```
GET /teams/:teamId/providers → { "configs": [...] } GET /teams/:teamId/providers → { "data": [...], "allow_team_providers": bool }
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... } POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
PUT /teams/:teamId/providers/:id PUT /teams/:teamId/providers/:id
DELETE /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:** **Team Models:**
``` ```
GET /teams/:teamId/models → { "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:** **Team Roles:**
``` ```
GET /teams/:teamId/roles → { "roles": [...] } GET /teams/:teamId/roles → { "data": {...} }
PUT /teams/:teamId/roles/:role ← { "permissions": {...} } PUT /teams/:teamId/roles/:role ← RoleConfig object
DELETE /teams/:teamId/roles/:role 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:** **Team Audit:**
``` ```
GET /teams/:teamId/audit → paginated audit log GET /teams/:teamId/audit → { "data": [...], "total": N, "page": N, "per_page": N }
GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types) 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:** **Team Usage:**
``` ```
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] } 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 & Resource Grants
Groups are ACL containers that decouple access from team membership. Groups are ACL containers that decouple access from team membership.
@@ -90,45 +154,58 @@ scope.
**My Groups:** **My Groups:**
``` ```
GET /groups/mine → { "groups": [...] } GET /groups/mine → { "data": [...] }
``` ```
**Auth:** Authenticated user.
**Admin Group CRUD:** **Admin Group CRUD:**
``` ```
GET /admin/groups → { "groups": [...] } GET /admin/groups → { "data": [...] }
POST /admin/groups ← { "name", "scope", "team_id" } POST /admin/groups ← { "name", "scope", "team_id"? }
GET /admin/groups/:id GET /admin/groups/:id → group object (unwrapped)
PUT /admin/groups/:id PUT /admin/groups/:id ← { "name"?, "description"?, "permissions"?, ... }
DELETE /admin/groups/:id 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:** **Group Members:**
``` ```
GET /admin/groups/:id/members → { "members": [...] } GET /admin/groups/:id/members → { "data": [...] }
POST /admin/groups/:id/members ← { "user_id" } POST /admin/groups/:id/members ← { "user_id" }
DELETE /admin/groups/:id/members/:userId DELETE /admin/groups/:id/members/:userId
``` ```
**Auth:** `RequireAdmin`.
**Resource Grants:** **Resource Grants:**
``` ```
GET /admin/grants/:type/:id → { "grant": {...} } GET /admin/grants/:type/:id → grant object (unwrapped)
PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] } PUT /admin/grants/:type/:id ← { "grant_scope", "granted_groups"?: [...] }
DELETE /admin/grants/:type/:id DELETE /admin/grants/:type/:id
``` ```
**Auth:** `RequireAdmin`.
`:type` is `persona` or `kb`. `:id` is the resource ID. `: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 | | `team_only` | Only the owning team |
| `global` | All authenticated users | | `global` | All authenticated users |
| `groups` | Members of specified groups | | `groups` | Members of specified groups |
Setting `grant_scope: "groups"` without `granted_groups` returns `400`.
### Permissions ### Permissions
Groups carry fine-grained permissions. Permission resolution: 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", ...] } GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
``` ```
**Auth:** `RequireAdmin`.
**Get user's effective permissions:** **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`): **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. resolved permission set. Returns 403 if the required permission is not present.
See [enums.md](enums.md) for the full permission constant list. See [enums.md](enums.md) for the full permission constant list.

View File

@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "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/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
@@ -82,6 +83,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role, Role: role,
IsActive: true, 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 { if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"database/sql" "database/sql"
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -191,16 +192,15 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
err := h.stores.Groups.Delete(c.Request.Context(), 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"}) c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return return
} }
if errors.Is(err, store.ErrSystemGroup) {
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
return
}
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return return
} }
@@ -428,8 +428,9 @@ func (h *GroupHandler) ListPermissions(c *gin.Context) {
// GET /api/v1/admin/users/:id/permissions // GET /api/v1/admin/users/:id/permissions
func (h *GroupHandler) GetUserPermissions(c *gin.Context) { func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
userID := c.Param("id") 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return return
@@ -440,5 +441,14 @@ func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
for p := range perms { for p := range perms {
list = append(list, p) 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})
} }

View File

@@ -170,18 +170,33 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Team self-service (same route group as production) // Team self-service (same route group as production)
teams := NewTeamHandler(nil) teams := NewTeamHandler(stores, nil)
protected.GET("/teams/mine", teams.MyTeams) protected.GET("/teams/mine", teams.MyTeams)
teamScoped := protected.Group("/teams/:teamId") teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin()) 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.GET("/providers", teams.ListTeamProviders)
teamScoped.POST("/providers", teams.CreateTeamProvider) teamScoped.POST("/providers", teams.CreateTeamProvider)
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) 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 // Team usage
teamUsage := NewUsageHandler(stores) teamUsage := NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage) teamScoped.GET("/usage", teamUsage.TeamUsage)
@@ -317,8 +332,12 @@ func setupHarness(t *testing.T) *testHarness {
admin.GET("/teams", teams.ListTeams) admin.GET("/teams", teams.ListTeams)
admin.POST("/teams", teams.CreateTeam) admin.POST("/teams", teams.CreateTeam)
admin.GET("/teams/:id", teams.GetTeam) 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.GET("/teams/:id/members", teams.ListMembers)
admin.POST("/teams/:id/members", teams.AddMember) 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.GET("/personas", personas.ListAdminPersonas)
admin.POST("/personas", personas.CreateAdminPersona) admin.POST("/personas", personas.CreateAdminPersona)
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 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.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) 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) // User groups (v0.16.0)
protected.GET("/groups/mine", groupAdm.MyGroups) protected.GET("/groups/mine", groupAdm.MyGroups)
@@ -1906,10 +1929,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String()) t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
} }
var roleList map[string]interface{} var roleResp map[string]interface{}
decode(w, &roleList) decode(w, &roleResp)
if len(roleList) != 0 { roleData, _ := roleResp["data"].(map[string]interface{})
t.Fatalf("team roles should be empty initially, got %d", len(roleList)) 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 // Set team role override
@@ -1926,8 +1953,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
// List should now have override // List should now have override
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
decode(w, &roleList) roleResp = map[string]interface{}{}
if _, ok := roleList["utility"]; !ok { 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") 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 // List should be empty again
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) 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 roleResp = map[string]interface{}{}
decode(w, &roleList) decode(w, &roleResp)
if _, ok := roleList["utility"]; ok { roleData, _ = roleResp["data"].(map[string]interface{})
t.Fatal("team roles should not have utility after delete") 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. // Verify the request succeeded (200) — that's the regression test.
// Channel content verification is covered by other tests. // 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
}

View File

@@ -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. // UpdateTeamRole sets a team role override.

View File

@@ -61,7 +61,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"providers": configs, "data": configs,
"allow_team_providers": isTeamProvidersAllowed(teamID), "allow_team_providers": isTeamProvidersAllowed(teamID),
}) })
} }
@@ -82,6 +82,7 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"` ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"` Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsPrivate bool `json:"is_private,omitempty"` IsPrivate bool `json:"is_private,omitempty"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -103,6 +104,12 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
configJSON = string(b) configJSON = string(b)
} }
headersJSON := "{}"
if req.Headers != nil {
b, _ := json.Marshal(req.Headers)
headersJSON = string(b)
}
// Encrypt the API key for team scope // Encrypt the API key for team scope
var apiKeyEnc, keyNonce []byte var apiKeyEnc, keyNonce []byte
if req.APIKey != "" { if req.APIKey != "" {
@@ -120,11 +127,11 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
id, err := database.InsertReturningID(` id, err := database.InsertReturningID(`
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
api_key_enc, key_nonce, key_scope, model_default, config, is_private) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb)
RETURNING id RETURNING id
`, "team", teamID, req.Name, req.Provider, req.Endpoint, `, "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 { if err != nil {
log.Printf("[WARN] Failed to create team provider: %v", err) 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"` APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"` ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"` Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,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) // Build dynamic update using ? placeholders (works on both dialects)
setClauses := []string{"updated_at = " + database.Q("NOW()")} setClauses := []string{"updated_at = " + database.Q("NOW()")}
args := []interface{}{} args := []interface{}{}
fieldCount := 0
addSet := func(col string, val interface{}) { addSet := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = ?") setClauses = append(setClauses, col+" = ?")
args = append(args, val) args = append(args, val)
fieldCount++
} }
if req.Name != nil { if req.Name != nil {
@@ -203,6 +213,15 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
b, _ := json.Marshal(req.Config) b, _ := json.Marshal(req.Config)
addSet("config", string(b)) 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) args = append(args, providerID, teamID)

View File

@@ -14,7 +14,9 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "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/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
) )
// ── Request types ─────────────────────────── // ── Request types ───────────────────────────
@@ -43,11 +45,12 @@ type updateMemberRequest struct {
// ── Handler ───────────────────────────────── // ── Handler ─────────────────────────────────
type TeamHandler struct{ type TeamHandler struct{
vault *crypto.KeyResolver stores store.Stores
vault *crypto.KeyResolver
} }
func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler { func NewTeamHandler(s store.Stores, vault *crypto.KeyResolver) *TeamHandler {
return &TeamHandler{vault: vault} return &TeamHandler{stores: s, vault: vault}
} }
// ── Admin: List All Teams ─────────────────── // ── Admin: List All Teams ───────────────────
@@ -61,59 +64,29 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
if perPage < 1 || perPage > 100 { if perPage < 1 || perPage > 100 {
perPage = 50 perPage = 50
} }
offset := (page - 1) * perPage
var total int all, err := h.stores.Teams.List(c.Request.Context())
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)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return return
} }
defer rows.Close() if all == nil {
all = []models.Team{}
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 teams == nil { total := len(all)
teams = []gin.H{}
// 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{ c.JSON(http.StatusOK, gin.H{
"data": teams, "data": all[offset:end],
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
@@ -131,12 +104,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
return return
} }
id, err := database.InsertReturningID(` team := &models.Team{
INSERT INTO teams (name, description, created_by) Name: req.Name,
VALUES ($1, $2, $3) Description: req.Description,
RETURNING id CreatedBy: adminID,
`, req.Name, req.Description, adminID) IsActive: true,
if err != nil { }
if err := h.stores.Teams.Create(c.Request.Context(), team); err != nil {
if database.IsUniqueViolation(err) { if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return return
@@ -145,8 +119,8 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
return return
} }
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name}) c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name})
AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name}) AuditLog(c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name})
} }
// ── Admin: Get Team ───────────────────────── // ── Admin: Get Team ─────────────────────────
@@ -154,18 +128,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
func (h *TeamHandler) GetTeam(c *gin.Context) { func (h *TeamHandler) GetTeam(c *gin.Context) {
teamID := c.Param("id") teamID := c.Param("id")
var name, desc, createdBy, settings string team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
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)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return return
@@ -175,17 +138,7 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, team)
"id": teamID,
"name": name,
"description": desc,
"created_by": createdBy,
"is_active": isActive,
"settings": settings,
"member_count": memberCount,
"created_at": createdAt,
"updated_at": updatedAt,
})
} }
// ── Admin: Update Team ────────────────────── // ── Admin: Update Team ──────────────────────
@@ -271,14 +224,20 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
func (h *TeamHandler) DeleteTeam(c *gin.Context) { func (h *TeamHandler) DeleteTeam(c *gin.Context) {
teamID := c.Param("id") teamID := c.Param("id")
ctx := c.Request.Context()
res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID) // Verify existence
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return 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 return
} }
@@ -291,38 +250,13 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
func (h *TeamHandler) ListMembers(c *gin.Context) { func (h *TeamHandler) ListMembers(c *gin.Context) {
teamID := getTeamID(c) teamID := getTeamID(c)
rows, err := database.DB.Query(database.Q(` members, err := h.stores.Teams.ListMembers(c.Request.Context(), teamID)
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)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return 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 { if members == nil {
members = []gin.H{} members = []models.TeamMember{}
} }
c.JSON(http.StatusOK, gin.H{"data": members}) c.JSON(http.StatusOK, gin.H{"data": members})
@@ -377,6 +311,7 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
// ── Members: Update Role ──────────────────── // ── Members: Update Role ────────────────────
func (h *TeamHandler) UpdateMember(c *gin.Context) { func (h *TeamHandler) UpdateMember(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId") memberID := c.Param("memberId")
var req updateMemberRequest var req updateMemberRequest
@@ -386,8 +321,8 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
} }
res, err := database.DB.Exec(database.Q(` res, err := database.DB.Exec(database.Q(`
UPDATE team_members SET role = $1 WHERE id = $2 UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3
`), req.Role, memberID) `), req.Role, memberID, teamID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return return
@@ -406,9 +341,10 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
// ── Members: Remove ───────────────────────── // ── Members: Remove ─────────────────────────
func (h *TeamHandler) RemoveMember(c *gin.Context) { func (h *TeamHandler) RemoveMember(c *gin.Context) {
teamID := getTeamID(c)
memberID := c.Param("memberId") 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
return return
@@ -429,42 +365,13 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
func (h *TeamHandler) MyTeams(c *gin.Context) { func (h *TeamHandler) MyTeams(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
rows, err := database.DB.Query(database.Q(` teams, err := h.stores.Teams.ListForUser(c.Request.Context(), userID)
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)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return 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 { if teams == nil {
teams = []gin.H{} teams = []models.Team{}
} }
c.JSON(http.StatusOK, gin.H{"data": teams}) c.JSON(http.StatusOK, gin.H{"data": teams})
@@ -581,11 +488,6 @@ func getTeamID(c *gin.Context) string {
return c.Param("id") 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. // IsTeamAdmin checks if a user is an admin of the given team.
func IsTeamAdmin(userID, teamID string) bool { func IsTeamAdmin(userID, teamID string) bool {
var role string var role string
@@ -706,11 +608,6 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
ORDER BY al.created_at DESC ORDER BY al.created_at DESC
` + limitOffset ` + limitOffset
if database.IsPostgres() {
// Re-convert placeholders for the full query
query = convertPlaceholders(query)
}
rows, err := database.DB.Query(query, args...) rows, err := database.DB.Query(query, args...)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
@@ -719,15 +616,15 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
defer rows.Close() defer rows.Close()
type entry struct { type entry struct {
ID string `json:"id"` ID string `json:"id"`
ActorID *string `json:"actor_id"` ActorID *string `json:"actor_id"`
ActorName *string `json:"actor_name"` ActorName *string `json:"actor_name"`
Action string `json:"action"` Action string `json:"action"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
ResourceID *string `json:"resource_id"` ResourceID *string `json:"resource_id"`
Metadata string `json:"metadata"` Metadata string `json:"metadata"`
IPAddress *string `json:"ip_address"` IPAddress *string `json:"ip_address"`
CreatedAt string `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
entries := make([]entry, 0) entries := make([]entry, 0)
@@ -735,7 +632,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
var e entry var e entry
var actorName sql.NullString var actorName sql.NullString
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, 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 continue
} }
if actorName.Valid { if actorName.Valid {

View File

@@ -84,7 +84,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
// Teams (admin routes for creating teams + adding members) // Teams (admin routes for creating teams + adding members)
teamH := NewTeamHandler(nil) teamH := NewTeamHandler(stores, nil)
admin := api.Group("/admin") admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
admin.POST("/teams", teamH.CreateTeam) admin.POST("/teams", teamH.CreateTeam)

View File

@@ -857,7 +857,7 @@ func main() {
protected.GET("/memories/count", memH.MemoryCount) protected.GET("/memories/count", memH.MemoryCount)
// Teams (user: my teams) // Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver) teams := handlers.NewTeamHandler(stores, keyResolver)
protected.GET("/teams/mine", teams.MyTeams) protected.GET("/teams/mine", teams.MyTeams)
// Groups (user: my groups — v0.16.0) // Groups (user: my groups — v0.16.0)
@@ -995,7 +995,7 @@ func main() {
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove) admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
// Teams (admin) // Teams (admin)
teamAdm := handlers.NewTeamHandler(keyResolver) teamAdm := handlers.NewTeamHandler(stores, keyResolver)
admin.GET("/teams", teamAdm.ListTeams) admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam) admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam) admin.GET("/teams/:id", teamAdm.GetTeam)

View File

@@ -97,6 +97,7 @@ type Team struct {
IsActive bool `json:"is_active" db:"is_active"` IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"` Settings JSONMap `json:"settings,omitempty" db:"settings"`
MemberCount int `json:"member_count,omitempty"` // computed MemberCount int `json:"member_count,omitempty"` // computed
MyRole string `json:"my_role,omitempty"` // computed, user context
} }
type TeamMember struct { type TeamMember struct {

View File

@@ -2,11 +2,17 @@ package store
import ( import (
"context" "context"
"errors"
"time" "time"
"git.gobha.me/xcaliber/chat-switchboard/models" "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 // STORES — Data Access Layer
// ========================================= // =========================================
@@ -197,6 +203,7 @@ type TeamStore interface {
Update(ctx context.Context, id string, fields map[string]interface{}) error Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error) List(ctx context.Context) ([]models.Team, error)
ListForUser(ctx context.Context, userID string) ([]models.Team, error) // active teams with MyRole
// Members // Members
AddMember(ctx context.Context, teamID, userID, role string) error AddMember(ctx context.Context, teamID, userID, role string) error

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
) )
// ── GroupStore ────────────────────────────── // ── GroupStore ──────────────────────────────
@@ -118,7 +119,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
return err return err
} }
if source == "system" { 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) res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
if err != nil { if err != nil {

View File

@@ -87,6 +87,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
return result, rows.Err() 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 ───────────────────────────────── // ── Members ─────────────────────────────────
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error { func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {

View File

@@ -150,7 +150,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
return err return err
} }
if source == "system" { 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) res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
if err != nil { if err != nil {

View File

@@ -94,6 +94,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
return result, rows.Err() 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 ───────────────────────────────── // ── Members ─────────────────────────────────
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error { func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {

View File

@@ -466,7 +466,7 @@ Object.assign(UI, {
// Handle policy check // Handle policy check
const allowed = resp.allow_team_providers !== false; const allowed = resp.allow_team_providers !== false;
if (addBtn) addBtn.style.display = allowed ? '' : 'none'; 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 }; throw { message: 'Team provider management is disabled by your administrator', _empty: true };
} }
return resp; return resp;