Changeset 0.28.0.7 (#179)

This commit is contained in:
2026-03-12 19:34:48 +00:00
parent 1a5eb707ba
commit f3f5afd14c
11 changed files with 1189 additions and 89 deletions

View File

@@ -5,16 +5,6 @@ prompt + model + provider config + KB bindings + grant scoping. Users
interact with Personas, not raw provider configs. Admins curate which interact with Personas, not raw provider configs. Admins curate which
Personas are available; team admins create team-scoped Personas. Personas are available; team admins create team-scoped Personas.
**Model inheritance:** A Persona inherits all properties of its
underlying model. This includes context window size, max output
tokens, supported capabilities (thinking, tools, vision, streaming),
input/output pricing, and provider status. When the frontend displays
a Persona, it resolves the underlying model's capabilities and shows
the same pills/badges (e.g. thinking, tools, 200k context) that the
raw model would show. The Persona adds identity (name, avatar, system
prompt, KB bindings) on top of the model's capabilities — it never
masks or reduces them.
Three scopes, three route namespaces, one domain: Three scopes, three route namespaces, one domain:
| Scope | Routes | Who manages | | Scope | Routes | Who manages |
@@ -29,67 +19,78 @@ All three return the same Persona object shape:
{ {
"id": "uuid", "id": "uuid",
"name": "Code Reviewer", "name": "Code Reviewer",
"handle": "code-reviewer",
"description": "Reviews code for bugs and style", "description": "Reviews code for bugs and style",
"system_prompt": "You are a senior code reviewer...", "icon": "🔍",
"model": "claude-sonnet-4-20250514", "avatar": "data:image/png;base64,...",
"base_model_id": "claude-sonnet-4-20250514",
"provider_config_id": "uuid|null", "provider_config_id": "uuid|null",
"system_prompt": "You are a senior code reviewer...",
"temperature": 0.7,
"max_tokens": 4096,
"thinking_budget": null,
"top_p": null,
"scope": "personal|team|global", "scope": "personal|team|global",
"owner_id": "uuid|null", "owner_id": "uuid|null",
"is_default": false, "created_by": "uuid",
"is_active": true, "is_active": true,
"avatar_url": "/api/v1/personas/uuid/avatar?v=123", "is_shared": false,
"inherited": { "memory_enabled": false,
"context_window": 200000, "memory_extraction_prompt": null,
"max_output_tokens": 8192, "grants": [],
"supports_vision": true, "kb_ids": [],
"supports_tools": true,
"supports_thinking": true,
"supports_streaming": true,
"input_price_per_m": 3.00,
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null"
},
"created_at": "...", "created_at": "...",
"updated_at": "..." "updated_at": "..."
} }
``` ```
The `inherited` block is populated by the backend from the resolved `grants` and `kb_ids` are loaded from junction tables, not stored on the
model capabilities at response time — it is not stored on the persona. persona row. All nullable fields (`provider_config_id`, `owner_id`,
If the underlying model's capabilities change (e.g. provider upgrades `temperature`, `max_tokens`, `thinking_budget`, `top_p`,
context window), the persona inherits the new values automatically. `memory_extraction_prompt`) are omitted from JSON when null.
The frontend uses `inherited` to render capability pills and context
size badges identical to the raw model. `handle` is auto-generated from `name` on creation if not provided
(e.g. "Code Reviewer" → "code-reviewer"). Used for @mention routing.
### Personal Personas ### Personal Personas
Gated by `allow_user_personas` policy. Gated by `allow_user_personas` policy.
``` ```
GET /personas → { "personas": [...] } GET /personas → { "data": [...] }
POST /personas ← { "name", "description", "system_prompt", "model", ... } POST /personas ← { "name", "base_model_id", ... }
PUT /personas/:id ← partial update PUT /personas/:id ← partial update
DELETE /personas/:id DELETE /personas/:id
``` ```
**Auth:** JWT required. `POST` requires `persona:create` permission.
`PUT`/`DELETE` require `persona:manage` permission and ownership check
(`scope == "personal"` and `owner_id == caller`).
### Team Personas ### Team Personas
``` ```
GET /teams/:teamId/personas → { "personas": [...] } GET /teams/:teamId/personas → { "data": [...] }
POST /teams/:teamId/personas ← same shape POST /teams/:teamId/personas ← same shape
PUT /teams/:teamId/personas/:id PUT /teams/:teamId/personas/:id ← partial update
DELETE /teams/:teamId/personas/:id DELETE /teams/:teamId/personas/:id
``` ```
**Auth:** JWT required. Team membership for `GET`, team admin for
`POST`/`PUT`/`DELETE`. All mutating endpoints verify the persona belongs
to the team in the URL path (`scope == "team"` and `owner_id == teamId`).
### Admin (Global) Personas ### Admin (Global) Personas
``` ```
GET /admin/personas → { "personas": [...] } GET /admin/personas → { "data": [...] }
POST /admin/personas ← same shape POST /admin/personas ← same shape
PUT /admin/personas/:id PUT /admin/personas/:id
DELETE /admin/personas/:id DELETE /admin/personas/:id
``` ```
**Auth:** Admin JWT required (middleware enforced).
### Persona KB Bindings ### Persona KB Bindings
A Persona can have Knowledge Bases bound to it. When a channel uses A Persona can have Knowledge Bases bound to it. When a channel uses
@@ -127,23 +128,41 @@ PUT /admin/personas/:id/knowledge-bases
context). When `false`, it's available via the `kb_search` tool but context). When `false`, it's available via the `kb_search` tool but
not auto-injected. not auto-injected.
Team-scoped KB binding endpoints verify the persona belongs to the
team in the URL path before reading or writing bindings.
### Persona Avatars ### Persona Avatars
Avatars are stored as `data:image/png;base64,...` data URIs in the
`avatar` column. Upload accepts a JSON body (not multipart):
```json
{ "image": "data:image/png;base64,..." }
``` ```
POST /personas/:id/avatar ← multipart/form-data (field: "avatar")
The image is decoded, resized to 128×128 PNG, and stored.
```
POST /personas/:id/avatar ← { "image": "base64..." }
DELETE /personas/:id/avatar DELETE /personas/:id/avatar
POST /admin/personas/:id/avatar POST /admin/personas/:id/avatar ← same
DELETE /admin/personas/:id/avatar DELETE /admin/personas/:id/avatar
POST /teams/:teamId/personas/:id/avatar ← same (verify team ownership)
DELETE /teams/:teamId/personas/:id/avatar
``` ```
**Auth:** Personal avatar routes verify the caller owns the persona
(`scope == "personal"` and `owner_id == caller`). Admin routes are
protected by admin middleware.
### Persona Tool Grants ### Persona Tool Grants
Control which tools a persona can use during completions. The completion Control which tools a persona can use during completions. The completion
handler applies tool grants as a second-pass allowlist. handler applies tool grants as a second-pass allowlist.
``` ```
GET /personas/:id/tool-grants → { "grants": ["web_search", "kb_search", ...] } GET /personas/:id/tool-grants → { "data": ["web_search", "kb_search", ...] }
PUT /personas/:id/tool-grants ← { "tool_ids": ["web_search", "calculator"] } PUT /personas/:id/tool-grants ← { "tool_names": ["web_search", "calculator"] }
``` ```
Admin equivalents: Admin equivalents:
@@ -153,6 +172,13 @@ GET /admin/personas/:id/tool-grants
PUT /admin/personas/:id/tool-grants PUT /admin/personas/:id/tool-grants
``` ```
Team equivalents (verify persona belongs to team):
```
GET /teams/:teamId/personas/:id/tool-grants
PUT /teams/:teamId/personas/:id/tool-grants
```
When a workflow version is published, persona tool grants at that moment When a workflow version is published, persona tool grants at that moment
are frozen into the version snapshot. are frozen into the version snapshot.
@@ -163,7 +189,7 @@ can be stamped onto new conversations.
``` ```
GET /persona-groups → { "data": [...] } GET /persona-groups → { "data": [...] }
POST /persona-groups ← { "name", "description", "scope", "team_id" } POST /persona-groups ← { "name", "description" }
GET /persona-groups/:id → group with members GET /persona-groups/:id → group with members
PUT /persona-groups/:id ← partial update PUT /persona-groups/:id ← partial update
DELETE /persona-groups/:id DELETE /persona-groups/:id
@@ -172,12 +198,13 @@ DELETE /persona-groups/:id
**Members:** **Members:**
``` ```
POST /persona-groups/:id/members ← { "persona_id", "is_leader": false, "sort_order": 0 } POST /persona-groups/:id/members ← { "persona_id", "is_leader": false }
DELETE /persona-groups/:id/members/:memberId DELETE /persona-groups/:id/members/:memberId
``` ```
`is_leader`: the leader persona responds when no @mention is present in `is_leader`: the leader persona responds when no @mention is present in
a group channel. Only one leader per group. a group channel. Only one leader per group. Setting a new leader
automatically clears the existing one.
**Persona Group Object:** **Persona Group Object:**
@@ -187,18 +214,30 @@ a group channel. Only one leader per group.
"name": "Support Team", "name": "Support Team",
"description": "...", "description": "...",
"owner_id": "uuid", "owner_id": "uuid",
"scope": "personal|team|global", "scope": "personal",
"team_id": "uuid|null", "team_id": null,
"members": [ "members": [
{ "id": "uuid", "persona_id": "uuid", "is_leader": true, "sort_order": 0 } {
"id": "uuid",
"group_id": "uuid",
"persona_id": "uuid",
"is_leader": true,
"sort_order": 0,
"persona_name": "...",
"persona_handle": "...",
"persona_avatar": "..."
}
], ],
"created_at": "...", "created_at": "...",
"updated_at": "..." "updated_at": "..."
} }
``` ```
**Note:** Persona groups are currently personal-scope only. The `scope`
and `team_id` fields exist in the schema but `POST` hardcodes
`scope = 'personal'`. Team-scoped persona groups are a future item.
### Resource Grants ### Resource Grants
See [teams.md](teams.md) §15.3 for the grant system. Personas use grants See [teams.md](teams.md) §15.3 for the grant system. Personas use grants
to control who can see and use them beyond their base scope. to control who can see and use them beyond their base scope.

View File

@@ -214,6 +214,19 @@ func setupHarness(t *testing.T) *testHarness {
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete) teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete)
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow) teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun) teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)
// Team personas (v0.28.0-audit)
teamPersonas := NewPersonaHandler(stores)
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs)
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs)
teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants)
teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants)
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar)
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar)
} }
// Team task viewing for all members (v0.28.0-audit) // Team task viewing for all members (v0.28.0-audit)
@@ -240,8 +253,24 @@ func setupHarness(t *testing.T) *testHarness {
personas := NewPersonaHandler(stores) personas := NewPersonaHandler(stores)
protected.GET("/personas", personas.ListUserPersonas) protected.GET("/personas", personas.ListUserPersonas)
protected.POST("/personas", personas.CreateUserPersona) protected.POST("/personas", personas.CreateUserPersona)
protected.PUT("/personas/:id", personas.UpdateUserPersona)
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Persona Groups
pgH := NewPersonaGroupHandler()
protected.GET("/persona-groups", pgH.List)
protected.POST("/persona-groups", pgH.Create)
protected.GET("/persona-groups/:id", pgH.Get)
protected.PUT("/persona-groups/:id", pgH.Update)
protected.DELETE("/persona-groups/:id", pgH.Delete)
protected.POST("/persona-groups/:id/members", pgH.AddMember)
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
// Notes // Notes
notes := NewNoteHandler(stores) notes := NewNoteHandler(stores)
@@ -340,8 +369,14 @@ func setupHarness(t *testing.T) *testHarness {
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember) admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
admin.GET("/personas", personas.ListAdminPersonas) admin.GET("/personas", personas.ListAdminPersonas)
admin.POST("/personas", personas.CreateAdminPersona) admin.POST("/personas", personas.CreateAdminPersona)
admin.PUT("/personas/:id", personas.UpdateAdminPersona)
admin.DELETE("/personas/:id", personas.DeleteAdminPersona)
admin.POST("/personas/:id/avatar", UploadPersonaAvatar)
admin.DELETE("/personas/:id/avatar", DeletePersonaAvatar)
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Admin groups (v0.16.0) // Admin groups (v0.16.0)
groupAdm := NewGroupHandler(stores) groupAdm := NewGroupHandler(stores)
@@ -2921,7 +2956,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
w = h.request("GET", "/api/v1/personas", userToken, nil) w = h.request("GET", "/api/v1/personas", userToken, nil)
var personasResp map[string]interface{} var personasResp map[string]interface{}
decode(w, &personasResp) decode(w, &personasResp)
personas, _ := personasResp["personas"].([]interface{}) personas, _ := personasResp["data"].([]interface{})
for _, p := range personas { for _, p := range personas {
pm := p.(map[string]interface{}) pm := p.(map[string]interface{})
if pm["id"] == personaID { if pm["id"] == personaID {
@@ -2955,7 +2990,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
} }
decode(w, &personasResp) decode(w, &personasResp)
personas, _ = personasResp["personas"].([]interface{}) personas, _ = personasResp["data"].([]interface{})
found := false found := false
for _, p := range personas { for _, p := range personas {
pm := p.(map[string]interface{}) pm := p.(map[string]interface{})
@@ -2973,7 +3008,7 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
w = h.request("GET", "/api/v1/personas", userToken, nil) w = h.request("GET", "/api/v1/personas", userToken, nil)
decode(w, &personasResp) decode(w, &personasResp)
personas, _ = personasResp["personas"].([]interface{}) personas, _ = personasResp["data"].([]interface{})
for _, p := range personas { for _, p := range personas {
pm := p.(map[string]interface{}) pm := p.(map[string]interface{})
if pm["id"] == personaID { if pm["id"] == personaID {
@@ -3979,3 +4014,828 @@ func mapKeys(m map[string]interface{}) []string {
} }
return keys return keys
} }
// ═══════════════════════════════════════════════════════════════
// PERSONA AUDIT TESTS (v0.28.0-audit CS2)
// ═══════════════════════════════════════════════════════════════
// ── Persona CRUD ────────────────────────────
func TestPersona_CRUD_Personal(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com")
_ = adminID
// Enable persona creation policy
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Create
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "My Helper", "base_model_id": "test-model",
"system_prompt": "You are helpful.", "description": "Test persona",
})
if w.Code != http.StatusCreated {
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["handle"] == nil || created["handle"] == "" {
t.Error("handle should be auto-generated from name")
}
// Update
w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{
"name": "My Updated Helper", "description": "Updated desc",
})
if w.Code != http.StatusOK {
t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// List — verify updated name appears
w = h.request("GET", "/api/v1/personas", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
data := listResp["data"].([]interface{})
found := false
for _, p := range data {
pm := p.(map[string]interface{})
if pm["id"] == personaID && pm["name"] == "My Updated Helper" {
found = true
}
}
if !found {
t.Error("updated persona not found in list with new name")
}
// Delete
w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestPersona_Update_OwnershipCheck(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("own1", "own1@test.com")
otherID := database.SeedTestUser(t, "own2", "own2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
otherToken := makeToken(otherID, "own2@test.com", "user")
// Enable persona creation
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Admin creates a personal persona
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "Admin's Bot", "base_model_id": "test-model",
})
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Other user tries to update — should be forbidden
w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{
"name": "Stolen",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
// Other user tries to delete — should be forbidden
w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestPersona_AdminCRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("apadmin", "apadmin@test.com")
// Create global persona via admin
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Global Bot", "base_model_id": "test-model",
"system_prompt": "You are global.",
})
if w.Code != http.StatusCreated {
t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["scope"] != "global" {
t.Fatalf("admin persona scope: want 'global', got %v", created["scope"])
}
// Update
w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{
"name": "Global Bot v2",
})
if w.Code != http.StatusOK {
t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String())
}
// List
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("admin list should return 'data' key")
}
// Delete
w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Envelope + Nil Slice ────────────────────
func TestPersona_EnvelopeKey(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("envadm", "envadm@test.com")
// User list
w := h.request("GET", "/api/v1/personas", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
if _, ok := resp["personas"]; ok {
t.Error("response should use 'data' key, not 'personas'")
}
if resp["data"] == nil {
t.Error("response should have 'data' key")
}
// Admin list
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
decode(w, &resp)
if _, ok := resp["personas"]; ok {
t.Error("admin response should use 'data' key, not 'personas'")
}
if resp["data"] == nil {
t.Error("admin response should have 'data' key")
}
}
func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("niladm", "niladm@test.com")
// Fresh user, no personas — should get [] not null
w := h.request("GET", "/api/v1/personas", adminToken, nil)
// Check raw JSON contains [] not null
body := w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("data should be [] not null for empty persona list")
}
if !strings.Contains(body, `"data":[]`) {
t.Fatalf("data should be empty array, got: %s", body)
}
// Admin list
w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
body = w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("admin data should be [] not null for empty persona list")
}
}
// ── Tool Grants ─────────────────────────────
func TestPersona_ToolGrants_RoundTrip(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tgadm", "tgadm@test.com")
// Create persona
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Grant Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Initially empty
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var grantResp map[string]interface{}
decode(w, &grantResp)
grants := grantResp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("initial grants: want 0, got %d", len(grants))
}
// Set grants
w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{"web_search", "calculator", "kb_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
decode(w, &grantResp)
grants = grantResp["data"].([]interface{})
if len(grants) != 3 {
t.Fatalf("grants after set: want 3, got %d", len(grants))
}
// Clear grants
w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{},
})
if w.Code != http.StatusOK {
t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back — should be empty
w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
decode(w, &grantResp)
grants = grantResp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("grants after clear: want 0, got %d", len(grants))
}
// Nil-slice check: verify "data":[] not "data":null
body := w.Body.String()
if strings.Contains(body, `"data":null`) {
t.Fatal("tool grants should return [] not null when empty")
}
}
func TestPersona_ToolGrants_UserScope(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tguadm", "tguadm@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Create personal persona
w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
"name": "User Grant Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Set via user endpoint
w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
"tool_names": []string{"web_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back via user endpoint
w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var grantResp map[string]interface{}
decode(w, &grantResp)
grants := grantResp["data"].([]interface{})
if len(grants) != 1 {
t.Fatalf("user grants: want 1, got %d", len(grants))
}
}
// ── Team Persona CRUD + Scope Checks ────────
func TestTeamPersona_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("tpadm", "tpadm@test.com")
// Create a team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Persona Team",
})
var teamResp map[string]interface{}
decode(w, &teamResp)
teamID := teamResp["id"].(string)
// Create team persona
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
"name": "Team Bot", "base_model_id": "test-model",
"system_prompt": "You are a team bot.",
})
if w.Code != http.StatusCreated {
t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
if created["scope"] != "team" {
t.Fatalf("team persona scope: want 'team', got %v", created["scope"])
}
// List team personas
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("team persona list should use 'data' key")
}
items := listResp["data"].([]interface{})
if len(items) != 1 {
t.Fatalf("team personas: want 1, got %d", len(items))
}
// Update team persona
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{
"name": "Team Bot v2",
})
if w.Code != http.StatusOK {
t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// Delete team persona
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
decode(w, &listResp)
items = listResp["data"].([]interface{})
if len(items) != 0 {
t.Fatalf("team personas after delete: want 0, got %d", len(items))
}
}
func TestTeamPersona_Delete_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Team Alpha",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Team Beta",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team Alpha
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to delete it via Team Beta route — should fail
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to update it via Team Beta route — should fail
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{
"name": "Stolen",
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String())
}
// Delete via correct team — should succeed
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestTeamPersona_KB_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "KB Team A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "KB Team B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('KB Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to read KBs via Team B — should fail
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to set KBs via Team B — should fail
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{
"kb_ids": []string{},
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String())
}
// Read via correct team — should succeed
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Persona Groups ──────────────────────────
func TestPersonaGroup_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgadm", "pgadm@test.com")
// Create group
w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Support Team", "description": "Front-line support personas",
})
if w.Code != http.StatusCreated {
t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
groupID := created["id"].(string)
if created["scope"] != "personal" {
t.Fatalf("group scope: want 'personal', got %v", created["scope"])
}
// Get
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetched map[string]interface{}
decode(w, &fetched)
if fetched["name"] != "Support Team" {
t.Fatalf("group name: want 'Support Team', got %v", fetched["name"])
}
// Update
w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{
"name": "Support Team v2",
})
if w.Code != http.StatusOK {
t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
}
// List
w = h.request("GET", "/api/v1/persona-groups", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]interface{}
decode(w, &listResp)
if listResp["data"] == nil {
t.Fatal("group list should use 'data' key")
}
if _, ok := listResp["groups"]; ok {
t.Error("group list should NOT use 'groups' key")
}
// Delete
w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("get deleted group: want 404, got %d", w.Code)
}
}
func TestPersonaGroup_Members(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com")
// Create a persona for the group
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Group Persona A", "base_model_id": "test-model",
})
var pA map[string]interface{}
decode(w, &pA)
personaAID := pA["id"].(string)
w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
"name": "Group Persona B", "base_model_id": "test-model",
})
var pB map[string]interface{}
decode(w, &pB)
personaBID := pB["id"].(string)
// Create group
w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Two-Bot Team",
})
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
// Add member A as leader
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaAID, "is_leader": true,
})
if w.Code != http.StatusCreated {
t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String())
}
// Add member B
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaBID,
})
if w.Code != http.StatusCreated {
t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String())
}
// Get group — should have 2 members
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetched map[string]interface{}
decode(w, &fetched)
members := fetched["members"].([]interface{})
if len(members) != 2 {
t.Fatalf("members: want 2, got %d", len(members))
}
// Verify leader is first (sorted by is_leader DESC)
first := members[0].(map[string]interface{})
if first["is_leader"] != true {
t.Error("first member should be the leader")
}
// Swap leader to B — should clear A's leader flag
w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
"persona_id": personaBID, "is_leader": true,
})
if w.Code != http.StatusCreated {
t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String())
}
// Get group — verify only one leader
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
decode(w, &fetched)
members = fetched["members"].([]interface{})
leaderCount := 0
for _, m := range members {
mm := m.(map[string]interface{})
if mm["is_leader"] == true {
leaderCount++
}
}
if leaderCount != 1 {
t.Fatalf("leaders after swap: want 1, got %d", leaderCount)
}
// Remove member A
memberAID := ""
for _, m := range members {
mm := m.(map[string]interface{})
if mm["persona_id"] == personaAID {
memberAID = mm["id"].(string)
}
}
if memberAID == "" {
t.Fatal("could not find member A in group")
}
w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
}
// Get group — should have 1 member
w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
decode(w, &fetched)
members = fetched["members"].([]interface{})
if len(members) != 1 {
t.Fatalf("members after remove: want 1, got %d", len(members))
}
}
func TestPersonaGroup_OwnershipCheck(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgown1", "pgown1@test.com")
otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
otherToken := makeToken(otherID, "pgown2@test.com", "user")
// Admin creates a group
w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
"name": "Admin's Group",
})
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
// Other user tries to update — should fail
w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{
"name": "Stolen Group",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
}
// Other user tries to delete — should fail
w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String())
}
// Other user list — should not see admin's group
w = h.request("GET", "/api/v1/persona-groups", otherToken, nil)
var listResp map[string]interface{}
decode(w, &listResp)
items := listResp["data"].([]interface{})
if len(items) != 0 {
t.Fatalf("non-owner should see 0 groups, got %d", len(items))
}
}
func TestPersonaGroup_EnvelopeKey(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com")
w := h.request("GET", "/api/v1/persona-groups", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
if _, ok := resp["groups"]; ok {
t.Error("persona-groups list should use 'data' key, not 'groups'")
}
if resp["data"] == nil {
t.Error("persona-groups list should have 'data' key")
}
}
// ── Team Persona Tool Grants ────────────────
func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com")
// Create team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "ToolGrant Team",
})
var team map[string]interface{}
decode(w, &team)
teamID := team["id"].(string)
// Create team persona
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
"name": "TG Bot", "base_model_id": "test-model",
})
var created map[string]interface{}
decode(w, &created)
personaID := created["id"].(string)
// Initially empty
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
grants := resp["data"].([]interface{})
if len(grants) != 0 {
t.Fatalf("initial team grants: want 0, got %d", len(grants))
}
// Set
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{
"tool_names": []string{"web_search", "kb_search"},
})
if w.Code != http.StatusOK {
t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
// Read back
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
decode(w, &resp)
grants = resp["data"].([]interface{})
if len(grants) != 2 {
t.Fatalf("team grants after set: want 2, got %d", len(grants))
}
}
func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "TG Scope A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "TG Scope B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to read via Team B — 403
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to set via Team B — 403
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{
"tool_names": []string{"web_search"},
})
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String())
}
// Via correct team — 200
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
}
}
// ── Team Persona Avatar ─────────────────────
func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) {
h := setupHarness(t)
adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com")
// Create two teams
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Avatar Team A",
})
var teamA map[string]interface{}
decode(w, &teamA)
teamAID := teamA["id"].(string)
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
"name": "Avatar Team B",
})
var teamB map[string]interface{}
decode(w, &teamB)
teamBID := teamB["id"].(string)
// Create persona on Team A
personaID := seedInsertReturningID(t, `
INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true)
RETURNING id
`, teamAID, adminID)
// Try to delete avatar via Team B — 403
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String())
}
// Try to delete via correct team — should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too)
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil)
if w.Code != http.StatusOK && w.Code != http.StatusNotFound {
t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -51,7 +51,7 @@ func (h *PersonaGroupHandler) List(c *gin.Context) {
for rows.Next() { for rows.Next() {
var g models.PersonaGroup var g models.PersonaGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil { &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)); err != nil {
continue continue
} }
groups = append(groups, g) groups = append(groups, g)
@@ -62,7 +62,7 @@ func (h *PersonaGroupHandler) List(c *gin.Context) {
groups[i].Members = h.loadMembers(c, groups[i].ID) groups[i].Members = h.loadMembers(c, groups[i].ID)
} }
c.JSON(http.StatusOK, gin.H{"groups": groups}) c.JSON(http.StatusOK, gin.H{"data": groups})
} }
// ── Get ───────────────────────────────────────── // ── Get ─────────────────────────────────────────
@@ -76,7 +76,7 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) {
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = $1 AND owner_id = $2 FROM persona_groups WHERE id = $1 AND owner_id = $2
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, `), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return return
@@ -87,6 +87,9 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) {
} }
g.Members = h.loadMembers(c, g.ID) g.Members = h.loadMembers(c, g.ID)
if g.Members == nil {
g.Members = []models.PersonaGroupMember{}
}
c.JSON(http.StatusOK, g) c.JSON(http.StatusOK, g)
} }
@@ -119,7 +122,7 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) {
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
FROM persona_groups WHERE id = ? FROM persona_groups WHERE id = ?
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, `, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
} else { } else {
err := database.DB.QueryRowContext(c.Request.Context(), ` err := database.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO persona_groups (name, description, owner_id, scope) INSERT INTO persona_groups (name, description, owner_id, scope)
@@ -127,7 +130,7 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) {
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
`, strings.TrimSpace(req.Name), req.Description, userID).Scan( `, strings.TrimSpace(req.Name), req.Description, userID).Scan(
&g.ID, &g.Name, &g.Description, &g.OwnerID, &g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return return

View File

@@ -30,7 +30,10 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"personas": personas}) if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
} }
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) { func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
@@ -121,7 +124,10 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"personas": personas}) if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
} }
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) { func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
@@ -147,7 +153,34 @@ func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
c.JSON(http.StatusCreated, persona) c.JSON(http.StatusCreated, persona)
} }
// UpdateTeamPersona updates a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path.
func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
// DeleteTeamPersona deletes a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path before deleting.
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) { func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id") id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
@@ -167,7 +200,10 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"personas": personas}) if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
} }
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) { func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
@@ -286,6 +322,21 @@ func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": kbs}) c.JSON(http.StatusOK, gin.H{"data": kbs})
} }
// GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// SetPersonaKBs replaces the knowledge bases bound to a persona. // SetPersonaKBs replaces the knowledge bases bound to a persona.
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) { func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
personaID := c.Param("id") personaID := c.Param("id")
@@ -307,6 +358,30 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
// SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Persona Tool Grants (v0.25.0) ─────────── // ── Persona Tool Grants (v0.25.0) ───────────
// GetPersonaToolGrants returns the tool names granted to a persona. // GetPersonaToolGrants returns the tool names granted to a persona.
@@ -318,6 +393,9 @@ func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return return
} }
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants}) c.JSON(http.StatusOK, gin.H{"data": grants})
} }
@@ -351,3 +429,141 @@ func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
// ── User-Scoped Persona Avatar (with ownership check) ─────
// UploadUserPersonaAvatar uploads an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
UploadPersonaAvatar(c)
}
// DeleteUserPersonaAvatar deletes an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
DeletePersonaAvatar(c)
}
// ── Team-Scoped Helpers ─────────────────────
// requireTeamPersona loads a persona by ID and verifies it belongs to
// the team in the URL path. Returns the persona on success or writes
// an error response and returns nil.
func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona {
teamID := c.Param("teamId")
personaID := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return nil
}
if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID {
c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"})
return nil
}
return existing
}
// ── Team-Scoped Persona Tool Grants ─────────
// GetTeamPersonaToolGrants returns tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetTeamPersonaToolGrants replaces tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Team-Scoped Persona Avatar ──────────────
// UploadTeamPersonaAvatar uploads an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
UploadPersonaAvatar(c)
}
// DeleteTeamPersonaAvatar deletes an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
DeletePersonaAvatar(c)
}

View File

@@ -713,8 +713,8 @@ func main() {
protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona) protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona) protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona) protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar) protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar) protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
@@ -898,9 +898,14 @@ func main() {
teamPersonas := handlers.NewPersonaHandler(stores) teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona) teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0 teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0 teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0
teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0
teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.0
// Team role overrides // Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver) teamRoles := handlers.NewRolesHandler(stores, roleResolver)

View File

@@ -421,7 +421,7 @@ type PersonaGroup struct {
OwnerID string `json:"owner_id" db:"owner_id"` OwnerID string `json:"owner_id" db:"owner_id"`
Scope string `json:"scope" db:"scope"` Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"`
Members []PersonaGroupMember `json:"members,omitempty" db:"-"` Members []PersonaGroupMember `json:"members" db:"-"`
} }
type PersonaGroupMember struct { type PersonaGroupMember struct {

View File

@@ -129,7 +129,6 @@ type PersonaStore interface {
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
ListGlobal(ctx context.Context) ([]models.Persona, error) ListGlobal(ctx context.Context) ([]models.Persona, error)
ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own
// Grants // Grants
SetGrants(ctx context.Context, personaID string, grants []models.Grant) error SetGrants(ctx context.Context, personaID string, grants []models.Grant) error

View File

@@ -168,17 +168,6 @@ func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error)
return scanPersonas(rows) return scanPersonas(rows)
} }
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = $1 ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ────────────────────────────────── // ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert). // SetGrants replaces all grants for a Persona (delete + re-insert).

View File

@@ -175,17 +175,6 @@ func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error)
return scanPersonas(rows) return scanPersonas(rows)
} }
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = ? ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ────────────────────────────────── // ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert). // SetGrants replaces all grants for a Persona (delete + re-insert).

View File

@@ -662,7 +662,7 @@ Object.assign(UI, {
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>'; if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const data = await API.adminListPersonas(); const data = await API.adminListPersonas();
const list = data.personas || []; const list = data.data || [];
// Ensure form is initialized for dropdown population // Ensure form is initialized for dropdown population
ensureAdminPersonaForm(); ensureAdminPersonaForm();

View File

@@ -508,7 +508,7 @@ Object.assign(UI, {
el.innerHTML = '<div class="loading">Loading...</div>'; el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const resp = await API.teamListPersonas(teamId); const resp = await API.teamListPersonas(teamId);
const personas = resp.personas || []; const personas = resp.data || [];
el.innerHTML = personas.map(p => { el.innerHTML = personas.map(p => {
const icon = p.icon || ''; const icon = p.icon || '';
return `<div class="admin-persona-row" style="padding:6px 0"> return `<div class="admin-persona-row" style="padding:6px 0">
@@ -840,7 +840,7 @@ Object.assign(UI, {
try { try {
const data = await API.listUserPersonas(); const data = await API.listUserPersonas();
// Only show personal personas owned by user // Only show personal personas owned by user
const personas = (data.personas || []).filter(p => p.scope === 'personal'); const personas = (data.data || []).filter(p => p.scope === 'personal');
if (!personas.length) { if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>'; el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
return; return;