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),
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

View File

@@ -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" }
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": [...] }
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.

View File

@@ -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 {

View File

@@ -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 err != nil {
// Store layer returns a plain error for system groups.
if err.Error() == "system groups cannot be deleted" {
if errors.Is(err, store.ErrSystemGroup) {
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
return
}
if err != nil {
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})
}

View File

@@ -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,12 +1971,15 @@ 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 {
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")
}
}
}
// ═══════════════════════════════════════════
// USAGE TESTS
@@ -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
}

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.

View File

@@ -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)

View File

@@ -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{
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()
if all == nil {
all = []models.Team{}
}
total := len(all)
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
// In-memory pagination (teams are low-cardinality)
offset := (page - 1) * perPage
end := offset + perPage
if offset > total {
offset = total
}
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 {
teams = []gin.H{}
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"})
@@ -727,7 +624,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
ResourceID *string `json:"resource_id"`
Metadata string `json:"metadata"`
IPAddress *string `json:"ip_address"`
CreatedAt string `json:"created_at"`
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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;