Changeset 0.28.0.3 (#175)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -3,35 +3,58 @@
|
||||
### My Teams
|
||||
|
||||
```
|
||||
GET /teams/mine → { "teams": [...] }
|
||||
GET /teams/mine → { "data": [...] }
|
||||
```
|
||||
|
||||
Returns teams the current user is a member of.
|
||||
**Auth:** Authenticated user.
|
||||
|
||||
Returns teams the current user is a member of (active teams only).
|
||||
|
||||
Each team object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Engineering",
|
||||
"description": "...",
|
||||
"is_active": true,
|
||||
"settings": "{}",
|
||||
"my_role": "admin|member",
|
||||
"member_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Team Administration
|
||||
|
||||
Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
|
||||
|
||||
**Team CRUD (platform admin):**
|
||||
|
||||
```
|
||||
GET /admin/teams → { "teams": [...] }
|
||||
POST /admin/teams
|
||||
GET /admin/teams/:id
|
||||
PUT /admin/teams/:id
|
||||
GET /admin/teams → { "data": [...], "total": N, "page": N, "per_page": N }
|
||||
POST /admin/teams ← { "name", "description" } → { "id", "name" }
|
||||
GET /admin/teams/:id → team object (unwrapped)
|
||||
PUT /admin/teams/:id ← { "name"?, "description"?, "is_active"?, "settings"? }
|
||||
DELETE /admin/teams/:id
|
||||
```
|
||||
|
||||
**Members:**
|
||||
**Auth:** `RequireAdmin` (platform admin).
|
||||
|
||||
`POST` returns `201`. `PUT`/`DELETE` return `{"ok": true}`. `PUT` with
|
||||
no fields returns `400`. Duplicate name returns `409`.
|
||||
|
||||
**Members (admin routes):**
|
||||
|
||||
```
|
||||
GET /admin/teams/:id/members → { "members": [...] }
|
||||
POST /admin/teams/:id/members ← { "user_id", "role" }
|
||||
PUT /admin/teams/:id/members/:memberId ← { "role" }
|
||||
GET /admin/teams/:id/members → { "data": [...] }
|
||||
POST /admin/teams/:id/members ← { "user_id", "role" } → { "id" }
|
||||
PUT /admin/teams/:id/members/:memberId ← { "role" }
|
||||
DELETE /admin/teams/:id/members/:memberId
|
||||
```
|
||||
|
||||
**Team-scoped routes** (team admin, not platform admin):
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
`role` must be `admin` or `member`. Duplicate membership returns `409`.
|
||||
|
||||
**Team-scoped routes** (team admin self-service):
|
||||
|
||||
```
|
||||
GET /teams/:teamId/members
|
||||
@@ -40,47 +63,88 @@ PUT /teams/:teamId/members/:memberId
|
||||
DELETE /teams/:teamId/members/:memberId
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
Same handlers as admin routes; the `getTeamID()` helper reads either
|
||||
`:id` or `:teamId`.
|
||||
|
||||
**Team Providers:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/providers → { "configs": [...] }
|
||||
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
|
||||
GET /teams/:teamId/providers → { "data": [...], "allow_team_providers": bool }
|
||||
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
|
||||
PUT /teams/:teamId/providers/:id
|
||||
DELETE /teams/:teamId/providers/:id
|
||||
GET /teams/:teamId/providers/:id/models → { "models": [...] }
|
||||
GET /teams/:teamId/providers/:id/models → { "models": [...], "provider": "name" }
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
`GET` returns provider configs scoped to the team. The `models` endpoint
|
||||
performs a live query to the upstream provider.
|
||||
|
||||
**Team Models:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/models → { "models": [...] }
|
||||
```
|
||||
|
||||
Available models for this team (global + team-scoped).
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
**Team Personas:** See §4.2.
|
||||
Returns all models available to this team: global admin models with
|
||||
`visibility IN ('enabled', 'team')` plus live-queried team provider
|
||||
models with `source` field (`"global"` or `"team"`).
|
||||
|
||||
**Team Groups:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/groups → { "data": [...] }
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
Returns groups scoped to this team.
|
||||
|
||||
**Team Personas:** See personas.md §Team Personas.
|
||||
|
||||
**Team Roles:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/roles → { "roles": [...] }
|
||||
PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
|
||||
GET /teams/:teamId/roles → { "data": {...} }
|
||||
PUT /teams/:teamId/roles/:role ← RoleConfig object
|
||||
DELETE /teams/:teamId/roles/:role
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
`GET` returns the team's `model_roles` settings map (composite object,
|
||||
not an array). Initially empty `{}`. Each key is a role name, value is
|
||||
a `RoleConfig`. `PUT` validates the role name. `DELETE` removes the
|
||||
override, falling back to the global role definition.
|
||||
|
||||
**Team Audit:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/audit → paginated audit log
|
||||
GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
|
||||
GET /teams/:teamId/audit → { "data": [...], "total": N, "page": N, "per_page": N }
|
||||
GET /teams/:teamId/audit/actions → { "actions": [...] }
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
Audit log is scoped to actions performed by team members. Supports
|
||||
query filters: `?action=`, `?actor_id=`, `?resource_type=`.
|
||||
|
||||
**Team Usage:**
|
||||
|
||||
```
|
||||
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
|
||||
```
|
||||
|
||||
**Auth:** `RequireTeamAdmin`.
|
||||
|
||||
Returns usage against team-owned providers. Supports query params for
|
||||
date range filtering.
|
||||
|
||||
### Groups & Resource Grants
|
||||
|
||||
Groups are ACL containers that decouple access from team membership.
|
||||
@@ -90,45 +154,58 @@ scope.
|
||||
**My Groups:**
|
||||
|
||||
```
|
||||
GET /groups/mine → { "groups": [...] }
|
||||
GET /groups/mine → { "data": [...] }
|
||||
```
|
||||
|
||||
**Auth:** Authenticated user.
|
||||
|
||||
**Admin Group CRUD:**
|
||||
|
||||
```
|
||||
GET /admin/groups → { "groups": [...] }
|
||||
POST /admin/groups ← { "name", "scope", "team_id" }
|
||||
GET /admin/groups/:id
|
||||
PUT /admin/groups/:id
|
||||
GET /admin/groups → { "data": [...] }
|
||||
POST /admin/groups ← { "name", "scope", "team_id"? }
|
||||
GET /admin/groups/:id → group object (unwrapped)
|
||||
PUT /admin/groups/:id ← { "name"?, "description"?, "permissions"?, ... }
|
||||
DELETE /admin/groups/:id
|
||||
```
|
||||
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
`POST` returns `201`. Duplicate name returns `409`. Deleting a
|
||||
system-sourced group (e.g. Everyone) returns `400`.
|
||||
|
||||
**Group Members:**
|
||||
|
||||
```
|
||||
GET /admin/groups/:id/members → { "members": [...] }
|
||||
GET /admin/groups/:id/members → { "data": [...] }
|
||||
POST /admin/groups/:id/members ← { "user_id" }
|
||||
DELETE /admin/groups/:id/members/:userId
|
||||
```
|
||||
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
**Resource Grants:**
|
||||
|
||||
```
|
||||
GET /admin/grants/:type/:id → { "grant": {...} }
|
||||
PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
|
||||
GET /admin/grants/:type/:id → grant object (unwrapped)
|
||||
PUT /admin/grants/:type/:id ← { "grant_scope", "granted_groups"?: [...] }
|
||||
DELETE /admin/grants/:type/:id
|
||||
```
|
||||
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
`:type` is `persona` or `kb`. `:id` is the resource ID.
|
||||
|
||||
Grant types:
|
||||
Grant scopes:
|
||||
|
||||
| `grant_type` | Visibility |
|
||||
|--------------|-----------|
|
||||
| `grant_scope` | Visibility |
|
||||
|---------------|-----------|
|
||||
| `team_only` | Only the owning team |
|
||||
| `global` | All authenticated users |
|
||||
| `groups` | Members of specified groups |
|
||||
|
||||
Setting `grant_scope: "groups"` without `granted_groups` returns `400`.
|
||||
|
||||
### Permissions
|
||||
|
||||
Groups carry fine-grained permissions. Permission resolution:
|
||||
@@ -140,13 +217,18 @@ union of all group permissions + Everyone group permissions.
|
||||
GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
|
||||
```
|
||||
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
**Get user's effective permissions:**
|
||||
|
||||
```
|
||||
GET /admin/users/:id/permissions → { "permissions": [...], "groups": [...] }
|
||||
GET /admin/users/:id/permissions → { "permissions": [...], "user_id": "...", "groups": [...] }
|
||||
```
|
||||
|
||||
Returns the resolved permission set and contributing groups.
|
||||
**Auth:** `RequireAdmin`.
|
||||
|
||||
Returns the resolved permission set and contributing group IDs
|
||||
(always includes the Everyone group).
|
||||
|
||||
**Group permission fields** (set via `PUT /admin/groups/:id`):
|
||||
|
||||
@@ -174,4 +256,3 @@ row needed). Editable by admins, cannot be deleted (`source=system`).
|
||||
resolved permission set. Returns 403 if the required permission is not present.
|
||||
|
||||
See [enums.md](enums.md) for the full permission constant list.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -191,16 +192,15 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
err := h.stores.Groups.Delete(c.Request.Context(), id)
|
||||
if err == sql.ErrNoRows {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrSystemGroup) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Store layer returns a plain error for system groups.
|
||||
if err.Error() == "system groups cannot be deleted" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
@@ -428,8 +428,9 @@ func (h *GroupHandler) ListPermissions(c *gin.Context) {
|
||||
// GET /api/v1/admin/users/:id/permissions
|
||||
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
perms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID)
|
||||
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
|
||||
return
|
||||
@@ -440,5 +441,14 @@ func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
||||
for p := range perms {
|
||||
list = append(list, p)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID})
|
||||
|
||||
// Contributing groups: explicit membership + Everyone
|
||||
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
|
||||
if groupIDs == nil {
|
||||
groupIDs = []string{}
|
||||
}
|
||||
// Everyone group always contributes
|
||||
groupIDs = append(groupIDs, auth.EveryoneGroupID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID, "groups": groupIDs})
|
||||
}
|
||||
|
||||
@@ -170,18 +170,33 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||
|
||||
// Team self-service (same route group as production)
|
||||
teams := NewTeamHandler(nil)
|
||||
teams := NewTeamHandler(stores, nil)
|
||||
protected.GET("/teams/mine", teams.MyTeams)
|
||||
|
||||
teamScoped := protected.Group("/teams/:teamId")
|
||||
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||
{
|
||||
// Team members (team admin self-service)
|
||||
teamScoped.GET("/members", teams.ListMembers)
|
||||
teamScoped.POST("/members", teams.AddMember)
|
||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||
|
||||
// Team providers
|
||||
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team audit
|
||||
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||
|
||||
// Team groups
|
||||
teamGroupH := NewGroupHandler(stores)
|
||||
teamScoped.GET("/groups", teamGroupH.ListTeamGroups)
|
||||
|
||||
// Team usage
|
||||
teamUsage := NewUsageHandler(stores)
|
||||
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||
@@ -317,8 +332,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/teams", teams.ListTeams)
|
||||
admin.POST("/teams", teams.CreateTeam)
|
||||
admin.GET("/teams/:id", teams.GetTeam)
|
||||
admin.PUT("/teams/:id", teams.UpdateTeam)
|
||||
admin.DELETE("/teams/:id", teams.DeleteTeam)
|
||||
admin.GET("/teams/:id/members", teams.ListMembers)
|
||||
admin.POST("/teams/:id/members", teams.AddMember)
|
||||
admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember)
|
||||
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
|
||||
admin.GET("/personas", personas.ListAdminPersonas)
|
||||
admin.POST("/personas", personas.CreateAdminPersona)
|
||||
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
@@ -340,6 +359,10 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||
|
||||
// Admin permissions (v0.16.0)
|
||||
admin.GET("/permissions", groupAdm.ListPermissions)
|
||||
admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)
|
||||
|
||||
// User groups (v0.16.0)
|
||||
protected.GET("/groups/mine", groupAdm.MyGroups)
|
||||
|
||||
@@ -1906,10 +1929,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var roleList map[string]interface{}
|
||||
decode(w, &roleList)
|
||||
if len(roleList) != 0 {
|
||||
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
|
||||
var roleResp map[string]interface{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ := roleResp["data"].(map[string]interface{})
|
||||
if roleData == nil {
|
||||
roleData = map[string]interface{}{}
|
||||
}
|
||||
if len(roleData) != 0 {
|
||||
t.Fatalf("team roles should be empty initially, got %d", len(roleData))
|
||||
}
|
||||
|
||||
// Set team role override
|
||||
@@ -1926,8 +1953,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// List should now have override
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; !ok {
|
||||
roleResp = map[string]interface{}{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||
if roleData == nil || len(roleData) == 0 {
|
||||
t.Fatal("team roles should have utility after update")
|
||||
}
|
||||
if _, ok := roleData["utility"]; !ok {
|
||||
t.Fatal("team roles should have utility after update")
|
||||
}
|
||||
|
||||
@@ -1939,10 +1971,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
|
||||
// List should be empty again
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; ok {
|
||||
t.Fatal("team roles should not have utility after delete")
|
||||
roleResp = map[string]interface{}{}
|
||||
decode(w, &roleResp)
|
||||
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||
if roleData != nil {
|
||||
if _, ok := roleData["utility"]; ok {
|
||||
t.Fatal("team roles should not have utility after delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3383,3 +3418,564 @@ func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
|
||||
// Verify the request succeeded (200) — that's the regression test.
|
||||
// Channel content verification is covered by other tests.
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TEAMS ICD AUDIT — Phase 2 Tests
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// ── Admin Team CRUD Lifecycle ──────────────
|
||||
|
||||
func TestAudit_AdminTeamCRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── Create ──
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "Audit Team", "description": "For audit testing",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created map[string]interface{}
|
||||
decode(w, &created)
|
||||
teamID := created["id"].(string)
|
||||
if created["name"] != "Audit Team" {
|
||||
t.Fatalf("create response should have name, got %v", created["name"])
|
||||
}
|
||||
|
||||
// ── List (data envelope) ──
|
||||
w = h.request("GET", "/api/v1/admin/teams", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list teams: want 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
decode(w, &listResp)
|
||||
if _, ok := listResp["data"]; !ok {
|
||||
t.Fatal("GET /admin/teams must return 'data' key (envelope convention)")
|
||||
}
|
||||
if _, ok := listResp["total"]; !ok {
|
||||
t.Fatal("GET /admin/teams must return 'total' key (paginated)")
|
||||
}
|
||||
|
||||
// ── Get ──
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got map[string]interface{}
|
||||
decode(w, &got)
|
||||
if got["name"] != "Audit Team" {
|
||||
t.Fatalf("get team name mismatch: %v", got["name"])
|
||||
}
|
||||
|
||||
// ── Get nonexistent ──
|
||||
w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("get nonexistent team: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Update ──
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||
"name": "Renamed Team",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify update
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
decode(w, &got)
|
||||
if got["name"] != "Renamed Team" {
|
||||
t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"])
|
||||
}
|
||||
|
||||
// ── Update with no fields → 400 ──
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Duplicate name → 409 ──
|
||||
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "Second Team",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create second team: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||
"name": "Second Team",
|
||||
})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("deleted team: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Delete nonexistent → 404 ──
|
||||
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete nonexistent: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Member Update + Remove ───────────
|
||||
|
||||
func TestAudit_AdminMemberUpdateRemove(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "MemberTest",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
// Add member
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": userID, "role": "member"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Get member ID from listing
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
|
||||
var membersResp map[string]interface{}
|
||||
decode(w, &membersResp)
|
||||
members := membersResp["data"].([]interface{})
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(members))
|
||||
}
|
||||
memberID := members[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Update role → admin
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken,
|
||||
map[string]string{"role": "admin"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Update nonexistent member → 404
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken,
|
||||
map[string]string{"role": "member"})
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("update nonexistent member: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Remove member
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Remove nonexistent → 404
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("remove nonexistent: want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── C1: Cross-Team Member Mutation ─────────
|
||||
// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint.
|
||||
// A team admin for Team A can mutate membership in Team B by knowing
|
||||
// the team_members.id primary key.
|
||||
|
||||
func TestAudit_CrossTeamMemberMutation(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create two users
|
||||
aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
|
||||
bobID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
|
||||
|
||||
// Create Team A and Team B
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"})
|
||||
var tA map[string]interface{}
|
||||
decode(w, &tA)
|
||||
teamAID := tA["id"].(string)
|
||||
|
||||
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"})
|
||||
var tB map[string]interface{}
|
||||
decode(w, &tB)
|
||||
teamBID := tB["id"].(string)
|
||||
|
||||
// Alice is admin of Team A
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken,
|
||||
map[string]string{"user_id": aliceID, "role": "admin"})
|
||||
|
||||
// Bob is member of Team B
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken,
|
||||
map[string]string{"user_id": bobID, "role": "member"})
|
||||
|
||||
// Get Bob's team_members.id from Team B listing
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil)
|
||||
var bMembers map[string]interface{}
|
||||
decode(w, &bMembers)
|
||||
bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Alice (Team A admin) tries to update Bob's role in Team B
|
||||
// via the team-scoped route for Team A — should fail with 404 because
|
||||
// bobMemberID does not belong to Team A.
|
||||
aliceToken := makeToken(aliceID, "alice@test.com", "user")
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken,
|
||||
map[string]string{"role": "admin"})
|
||||
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+
|
||||
"UpdateMember WHERE clause has no team_id constraint — authorization bypass",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Alice (Team A admin) tries to remove Bob from Team B
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil)
|
||||
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||
t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+
|
||||
"RemoveMember WHERE clause has no team_id constraint — authorization bypass",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Audit Log ─────────────────────────
|
||||
|
||||
func TestAudit_TeamAuditLog(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create team + add admin as team member so audit actions are scoped
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "AuditTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
// ── Query audit log (team admin) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var auditResp map[string]interface{}
|
||||
decode(w, &auditResp)
|
||||
// Must use "data" envelope
|
||||
if _, ok := auditResp["data"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)")
|
||||
}
|
||||
if _, ok := auditResp["total"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)")
|
||||
}
|
||||
|
||||
// ── Query audit actions ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var actionsResp map[string]interface{}
|
||||
decode(w, &actionsResp)
|
||||
if _, ok := actionsResp["actions"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin Permissions Listing ──────────────
|
||||
|
||||
func TestAudit_AdminPermissions(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── List all permissions ──
|
||||
w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var permResp map[string]interface{}
|
||||
decode(w, &permResp)
|
||||
perms, ok := permResp["permissions"]
|
||||
if !ok {
|
||||
t.Fatal("GET /admin/permissions must return 'permissions' key")
|
||||
}
|
||||
permList := perms.([]interface{})
|
||||
if len(permList) == 0 {
|
||||
t.Fatal("permissions list should not be empty")
|
||||
}
|
||||
|
||||
// Verify known permissions are present
|
||||
found := false
|
||||
for _, p := range permList {
|
||||
if p.(string) == "model.use" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("permissions list should contain 'model.use'")
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Effective Permissions ─────────────
|
||||
|
||||
func TestAudit_UserEffectivePermissions(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "charlie", "charlie@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
|
||||
// ── Get permissions for user ──
|
||||
w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
if _, ok := resp["permissions"]; !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key")
|
||||
}
|
||||
if _, ok := resp["user_id"]; !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key")
|
||||
}
|
||||
// H10: must include contributing groups
|
||||
groups, ok := resp["groups"]
|
||||
if !ok {
|
||||
t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)")
|
||||
}
|
||||
groupList := groups.([]interface{})
|
||||
// Should always contain the Everyone group
|
||||
found := false
|
||||
for _, g := range groupList {
|
||||
if g.(string) == "00000000-0000-0000-0000-000000000001" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("groups should always include the Everyone group, got %v", groupList)
|
||||
}
|
||||
|
||||
// ── Get permissions for admin (should include their own set) ──
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get admin permissions: want 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Group Deletion Rejection ────────
|
||||
|
||||
func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
everyoneID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// Seed the Everyone group (TruncateAll wipes migration-seeded data)
|
||||
_, err := database.TestDB.Exec(dialectSQL(`
|
||||
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
|
||||
VALUES ($1, $2, $3, $4, NULL, $5, $6)
|
||||
`), everyoneID, "Everyone",
|
||||
"Implicit group — all authenticated users receive these permissions.",
|
||||
"global", "system", `["model.use","kb.read","channel.create"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Everyone group: %v", err)
|
||||
}
|
||||
|
||||
// Attempt to delete the Everyone system group → should fail with 400
|
||||
w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("delete system group: want 400, got %d: %s\n"+
|
||||
"System groups (source=system) must be protected from deletion",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource Grant DELETE Round-Trip ────────
|
||||
|
||||
func TestAudit_ResourceGrantDelete(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create a persona to grant
|
||||
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "Grant Test Persona",
|
||||
"base_model_id": "test-model",
|
||||
"scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var persona map[string]interface{}
|
||||
decode(w, &persona)
|
||||
personaID := persona["id"].(string)
|
||||
|
||||
// Set a grant
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken,
|
||||
map[string]interface{}{
|
||||
"grant_scope": "global",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify the grant exists
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get grant: want 200, got %d", w.Code)
|
||||
}
|
||||
var grantResp map[string]interface{}
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "global" {
|
||||
t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
|
||||
// DELETE the grant
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// After delete, GET should return default (team_only)
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get after delete: want 200, got %d", w.Code)
|
||||
}
|
||||
decode(w, &grantResp)
|
||||
if grantResp["grant_scope"] != "team_only" {
|
||||
t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── H8: Team Providers Envelope ────────────
|
||||
// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}.
|
||||
|
||||
func TestAudit_TeamProvidersEnvelope(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "ProvEnvTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Must use "data" not "providers"
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+
|
||||
"got keys: %v", mapKeys(resp))
|
||||
}
|
||||
if _, ok := resp["allow_team_providers"]; !ok {
|
||||
t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'")
|
||||
}
|
||||
}
|
||||
|
||||
// ── H9: Team Roles Envelope ────────────────
|
||||
// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}.
|
||||
|
||||
func TestAudit_TeamRolesEnvelope(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "RolesEnvTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Should be wrapped in {"data": {...}} per composite convention
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+
|
||||
"got keys: %v — bare map returned without wrapper", mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Groups Listing ────────────────────
|
||||
|
||||
func TestAudit_TeamGroupsListing(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "GroupsTeam",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Must use "data" envelope
|
||||
if _, ok := resp["data"]; !ok {
|
||||
t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: map keys for diagnostic output ──
|
||||
|
||||
func mapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request types ───────────────────────────
|
||||
@@ -43,11 +45,12 @@ type updateMemberRequest struct {
|
||||
// ── Handler ─────────────────────────────────
|
||||
|
||||
type TeamHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler {
|
||||
return &TeamHandler{vault: vault}
|
||||
func NewTeamHandler(s store.Stores, vault *crypto.KeyResolver) *TeamHandler {
|
||||
return &TeamHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// ── Admin: List All Teams ───────────────────
|
||||
@@ -61,59 +64,29 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 50
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM teams`)).Scan(&total); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count teams"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||
COALESCE(mc.cnt, 0) AS member_count
|
||||
FROM teams t
|
||||
LEFT JOIN (
|
||||
SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id
|
||||
) mc ON mc.team_id = t.id
|
||||
ORDER BY t.name ASC
|
||||
LIMIT $1 OFFSET $2
|
||||
`), perPage, offset)
|
||||
all, err := h.stores.Teams.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var teams []gin.H
|
||||
for rows.Next() {
|
||||
var id, name, desc, createdBy, settings string
|
||||
var isActive bool
|
||||
var memberCount int
|
||||
var createdAt, updatedAt time.Time
|
||||
if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings,
|
||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount); err != nil {
|
||||
continue
|
||||
}
|
||||
teams = append(teams, gin.H{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"created_by": createdBy,
|
||||
"is_active": isActive,
|
||||
"settings": settings,
|
||||
"member_count": memberCount,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
if all == nil {
|
||||
all = []models.Team{}
|
||||
}
|
||||
if teams == nil {
|
||||
teams = []gin.H{}
|
||||
total := len(all)
|
||||
|
||||
// In-memory pagination (teams are low-cardinality)
|
||||
offset := (page - 1) * perPage
|
||||
end := offset + perPage
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": teams,
|
||||
"data": all[offset:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
@@ -131,12 +104,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO teams (name, description, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, adminID)
|
||||
if err != nil {
|
||||
team := &models.Team{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CreatedBy: adminID,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := h.stores.Teams.Create(c.Request.Context(), team); err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||
return
|
||||
@@ -145,8 +119,8 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name})
|
||||
AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name})
|
||||
c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name})
|
||||
AuditLog(c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name})
|
||||
}
|
||||
|
||||
// ── Admin: Get Team ─────────────────────────
|
||||
@@ -154,18 +128,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||
teamID := c.Param("id")
|
||||
|
||||
var name, desc, createdBy, settings string
|
||||
var isActive bool
|
||||
var memberCount int
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT t.name, t.description, t.created_by, t.is_active,
|
||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id)
|
||||
FROM teams t WHERE t.id = $1
|
||||
`), teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
|
||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount)
|
||||
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
@@ -175,17 +138,7 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": teamID,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"created_by": createdBy,
|
||||
"is_active": isActive,
|
||||
"settings": settings,
|
||||
"member_count": memberCount,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
c.JSON(http.StatusOK, team)
|
||||
}
|
||||
|
||||
// ── Admin: Update Team ──────────────────────
|
||||
@@ -271,14 +224,20 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
||||
|
||||
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
teamID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID)
|
||||
if err != nil {
|
||||
// Verify existence
|
||||
if _, err := h.stores.Teams.GetByID(ctx, teamID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
|
||||
if err := h.stores.Teams.Delete(ctx, teamID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,38 +250,13 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT tm.id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, '') AS display_name, u.role AS user_role
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = $1
|
||||
ORDER BY tm.role ASC, u.email ASC
|
||||
`), teamID)
|
||||
members, err := h.stores.Teams.ListMembers(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []gin.H
|
||||
for rows.Next() {
|
||||
var id, userID, role, email, displayName, userRole, joinedAt string
|
||||
if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, gin.H{
|
||||
"id": id,
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"joined_at": joinedAt,
|
||||
"email": email,
|
||||
"display_name": displayName,
|
||||
"user_role": userRole,
|
||||
})
|
||||
}
|
||||
if members == nil {
|
||||
members = []gin.H{}
|
||||
members = []models.TeamMember{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": members})
|
||||
@@ -377,6 +311,7 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
|
||||
// ── Members: Update Role ────────────────────
|
||||
|
||||
func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
memberID := c.Param("memberId")
|
||||
|
||||
var req updateMemberRequest
|
||||
@@ -386,8 +321,8 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
}
|
||||
|
||||
res, err := database.DB.Exec(database.Q(`
|
||||
UPDATE team_members SET role = $1 WHERE id = $2
|
||||
`), req.Role, memberID)
|
||||
UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3
|
||||
`), req.Role, memberID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||
return
|
||||
@@ -406,9 +341,10 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||
// ── Members: Remove ─────────────────────────
|
||||
|
||||
func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
memberID := c.Param("memberId")
|
||||
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1`), memberID)
|
||||
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
||||
return
|
||||
@@ -429,42 +365,13 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||
func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT t.id, t.name, t.description, t.is_active,
|
||||
COALESCE(t.settings, '{}'),
|
||||
tm.role AS my_role,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.name ASC
|
||||
`), userID)
|
||||
teams, err := h.stores.Teams.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var teams []gin.H
|
||||
for rows.Next() {
|
||||
var id, name, desc, settings, myRole string
|
||||
var isActive bool
|
||||
var memberCount int
|
||||
if err := rows.Scan(&id, &name, &desc, &isActive, &settings, &myRole, &memberCount); err != nil {
|
||||
continue
|
||||
}
|
||||
teams = append(teams, gin.H{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"is_active": isActive,
|
||||
"settings": settings,
|
||||
"my_role": myRole,
|
||||
"member_count": memberCount,
|
||||
})
|
||||
}
|
||||
if teams == nil {
|
||||
teams = []gin.H{}
|
||||
teams = []models.Team{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": teams})
|
||||
@@ -581,11 +488,6 @@ func getTeamID(c *gin.Context) string {
|
||||
return c.Param("id")
|
||||
}
|
||||
|
||||
// isUniqueViolation checks if a PG/SQLite error is a unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
return database.IsUniqueViolation(err)
|
||||
}
|
||||
|
||||
// IsTeamAdmin checks if a user is an admin of the given team.
|
||||
func IsTeamAdmin(userID, teamID string) bool {
|
||||
var role string
|
||||
@@ -706,11 +608,6 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
ORDER BY al.created_at DESC
|
||||
` + limitOffset
|
||||
|
||||
if database.IsPostgres() {
|
||||
// Re-convert placeholders for the full query
|
||||
query = convertPlaceholders(query)
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
@@ -719,15 +616,15 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
defer rows.Close()
|
||||
|
||||
type entry struct {
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
Metadata string `json:"metadata"`
|
||||
IPAddress *string `json:"ip_address"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID *string `json:"resource_id"`
|
||||
Metadata string `json:"metadata"`
|
||||
IPAddress *string `json:"ip_address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
entries := make([]entry, 0)
|
||||
@@ -735,7 +632,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
var e entry
|
||||
var actorName sql.NullString
|
||||
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
||||
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil {
|
||||
continue
|
||||
}
|
||||
if actorName.Valid {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user