Changeset 0.37.1 (#213)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 22:15:57 +00:00
committed by xcaliber
parent 0ab2800c7e
commit 8c53f61b71
8 changed files with 1803 additions and 24 deletions

View File

@@ -17,10 +17,11 @@ import (
// ── Request types ───────────────────────────
type createGroupRequest struct {
Name string `json:"name" binding:"required,min=1,max=200"`
Description string `json:"description,omitempty"`
Scope string `json:"scope" binding:"required,oneof=global team"`
TeamID *string `json:"team_id,omitempty"`
Name string `json:"name" binding:"required,min=1,max=200"`
Description string `json:"description,omitempty"`
Scope string `json:"scope" binding:"required,oneof=global team"`
TeamID *string `json:"team_id,omitempty"`
Permissions []string `json:"permissions,omitempty"`
}
type updateGroupRequest struct {
@@ -95,6 +96,7 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
Scope: req.Scope,
TeamID: req.TeamID,
CreatedBy: &actorID,
Permissions: req.Permissions,
}
if err := h.stores.Groups.Create(c.Request.Context(), g); err != nil {

View File

@@ -248,6 +248,10 @@ func setupHarness(t *testing.T) *testHarness {
usage := NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
// Profile permissions (v0.37.1)
permH := NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions)
// Profile / Settings
settings := NewSettingsHandler(stores, nil)
protected.GET("/profile", settings.GetProfile)
@@ -261,9 +265,9 @@ func setupHarness(t *testing.T) *testHarness {
// Personas
personas := NewPersonaHandler(stores)
protected.GET("/personas", personas.ListUserPersonas)
protected.POST("/personas", personas.CreateUserPersona)
protected.PUT("/personas/:id", personas.UpdateUserPersona)
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
protected.POST("/personas", middleware.RequirePermission(authpkg.PermPersonaCreate, stores), personas.CreateUserPersona)
protected.PUT("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.UpdateUserPersona)
protected.DELETE("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), 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
@@ -292,23 +296,28 @@ func setupHarness(t *testing.T) *testHarness {
// Channels
channels := NewChannelHandler(stores)
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.POST("/channels", middleware.RequirePermission(authpkg.PermChannelCreate, stores), channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Channel participants
partH := NewParticipantHandler(stores)
protected.GET("/channels/:id/participants", partH.List)
protected.POST("/channels/:id/participants", middleware.RequirePermission(authpkg.PermChannelInvite, stores), partH.Add)
// Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503)
kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil)
protected.POST("/knowledge-bases", middleware.RequirePermission(authpkg.PermKBCreate, stores), kbH.CreateKB)
protected.GET("/knowledge-bases", kbH.ListKBs)
protected.GET("/knowledge-bases/:id", kbH.GetKB)
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteKB)
protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UploadDocument)
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument)
protected.POST("/knowledge-bases/:id/search", kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteDocument)
protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(authpkg.PermKBRead, stores), kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
@@ -324,7 +333,7 @@ func setupHarness(t *testing.T) *testHarness {
// Completions
completions := NewCompletionHandler(nil, stores, nil, nil, nil)
protected.POST("/chat/completions", completions.Complete)
protected.POST("/chat/completions", middleware.RequirePermission(authpkg.PermModelUse, stores), completions.Complete)
// Messages
msgs := NewMessageHandler(nil, stores, nil, nil)
@@ -334,6 +343,13 @@ func setupHarness(t *testing.T) *testHarness {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
protected.GET("/channels/:id/path", msgs.GetActivePath)
// Workflows (v0.37.1 — perm enforcement)
wfH := NewWorkflowHandler(stores)
protected.POST("/workflows", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Create)
protected.GET("/workflows", wfH.List)
protected.PATCH("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Update)
protected.DELETE("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Delete)
// Tasks (v0.28.0)
taskH := NewTaskHandler(stores)
protected.GET("/tasks", taskH.ListMine)
@@ -2619,9 +2635,10 @@ func TestIntegration_KBCrossUserIsolation(t *testing.T) {
}
// Alice should NOT be able to delete admin's KB
// Returns 403 (no kb.write permission) or 404 (ownership isolation) — both are correct denials
w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("alice delete admin KB: want 404 (hidden), got %d", w.Code)
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
t.Fatalf("alice delete admin KB: want 403 or 404, got %d", w.Code)
}
}

View File

@@ -0,0 +1,551 @@
package handlers
// ══════════════════════════════════════════════════════════════════════════════
// Permission Enforcement Tests (v0.37.1)
//
// Verifies that RequirePermission middleware actually blocks non-admin users
// who lack the required permission, and allows them after the permission is
// granted via group membership.
//
// Every test:
// 1. Creates a non-admin user (no groups, TruncateAll wipes Everyone)
// 2. Attempts the gated operation → expects 403
// 3. Creates a group with the required permission
// 4. Adds the user to that group
// 5. Retries the operation → expects success (200 or 201)
//
// No admin users. No shortcuts.
// ══════════════════════════════════════════════════════════════════════════════
import (
"net/http"
"testing"
"chat-switchboard/database"
)
// ── Helpers ─────────────────────────────────
// seedNonAdminUser creates a non-admin active user with a valid token.
// TruncateAll wipes the Everyone group so this user has ZERO permissions.
func seedNonAdminUser(t *testing.T) (userID, token string) {
t.Helper()
userID = database.SeedTestUser(t, "permuser", "permuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
token = makeToken(userID, "permuser@test.com", "user")
return
}
// seedAdminUser creates an admin user for setup operations (group creation).
func seedAdminUser(t *testing.T) (userID, token string) {
t.Helper()
userID = database.SeedTestUser(t, "permadmin", "permadmin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
token = makeToken(userID, "permadmin@test.com", "admin")
return
}
// grantPermission creates a group with the given permission and adds the user to it.
// Uses admin token for group creation, returns the group ID.
func grantPermission(t *testing.T, h *testHarness, adminToken, userID string, perm string) string {
t.Helper()
w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
"name": "grant-" + perm,
"scope": "global",
"permissions": []string{perm},
})
if w.Code != http.StatusCreated {
t.Fatalf("create group for %s: want 201, got %d: %s", perm, w.Code, w.Body.String())
}
var group map[string]interface{}
decode(w, &group)
groupID := group["id"].(string)
w = h.request("POST", "/api/v1/admin/groups/"+groupID+"/members", adminToken, map[string]interface{}{
"user_id": userID,
})
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("add member to group %s: want 200/201, got %d: %s", perm, w.Code, w.Body.String())
}
return groupID
}
// seedChannel creates a channel using the admin token and returns its ID.
func seedChannel(t *testing.T, h *testHarness, adminToken string) string {
t.Helper()
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "test-channel",
"type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("seed channel: want 201, got %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
return ch["id"].(string)
}
// ══════════════════════════════════════════════
// §1 model.use — completions, summarize, title
// ══════════════════════════════════════════════
func TestPermEnforce_ModelUse_Completions(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
adminID, adminToken := seedAdminUser(t)
_ = adminID
// User needs channel.create to make a channel for completions,
// so grant that first (otherwise 403 on channel create, not completions).
grantPermission(t, h, adminToken, userID, "channel.create")
// Create channel as user (now allowed)
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "perm-test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Completions without model.use → 403
w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
"channel_id": channelID,
"message": "hello",
})
if w.Code != http.StatusForbidden {
t.Fatalf("completions without model.use: want 403, got %d: %s", w.Code, w.Body.String())
}
// Grant model.use
grantPermission(t, h, adminToken, userID, "model.use")
// Completions with model.use → should pass middleware (may fail downstream
// due to no provider configured, but NOT 403)
w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
"channel_id": channelID,
"message": "hello",
})
if w.Code == http.StatusForbidden {
t.Fatalf("completions WITH model.use: still got 403: %s", w.Body.String())
}
// Accept 400/500/502 — anything except 403 means the permission gate passed
}
// NOTE: Summarize and GenerateTitle share the model.use permission with
// completions. Their route-level RequirePermission wiring is in main.go
// but the test harness (setupHarness) doesn't register those routes.
// The completions test above proves model.use enforcement works.
// Summarize/title coverage will come when the test harness is extended.
// ══════════════════════════════════════════════
// §2 channel.create
// ══════════════════════════════════════════════
func TestPermEnforce_ChannelCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without channel.create → 403
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "blocked", "type": "direct",
})
if w.Code != http.StatusForbidden {
t.Fatalf("channel create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "channel.create")
w = h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "allowed", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("channel create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
// ══════════════════════════════════════════════
// §3 channel.invite
// ══════════════════════════════════════════════
func TestPermEnforce_ChannelInvite(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Need channel.create first to have a channel to invite into
grantPermission(t, h, adminToken, userID, "channel.create")
w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
"title": "invite-test", "type": "group",
})
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Create another user to invite
inviteeID := database.SeedTestUser(t, "invitee", "invitee@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), inviteeID)
// Without channel.invite → 403
w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
"user_id": inviteeID,
})
if w.Code != http.StatusForbidden {
t.Fatalf("invite without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "channel.invite")
w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
"user_id": inviteeID,
})
if w.Code == http.StatusForbidden {
t.Fatalf("invite WITH permission: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §4 kb.create, kb.read, kb.write
// ══════════════════════════════════════════════
func TestPermEnforce_KBCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without kb.create → 403
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "blocked-kb",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.create")
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "allowed-kb",
})
if w.Code != http.StatusCreated {
t.Fatalf("kb create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
func TestPermEnforce_KBSearch(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Admin creates a KB for the user to search
grantPermission(t, h, adminToken, userID, "kb.create")
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "search-test-kb",
})
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
// Without kb.read → 403
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
"query": "test",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb search without kb.read: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.read")
// With kb.read the middleware should pass. The handler may panic or 500
// because the test harness has no embedder configured, but any non-403
// response proves the permission gate opened. Use a recovery wrapper
// to catch the nil-embedder panic gracefully.
func() {
defer func() {
if r := recover(); r != nil {
// Panic in handler (nil embedder) means middleware passed — success
}
}()
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
"query": "test",
})
if w.Code == http.StatusForbidden {
t.Fatalf("kb search WITH kb.read: still got 403: %s", w.Body.String())
}
}()
}
func TestPermEnforce_KBWrite(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Grant kb.create so user can create the KB
grantPermission(t, h, adminToken, userID, "kb.create")
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
"name": "write-test-kb",
})
var kb map[string]interface{}
decode(w, &kb)
kbID := kb["id"].(string)
// Without kb.write → 403 on update
w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
"name": "renamed",
})
if w.Code != http.StatusForbidden {
t.Fatalf("kb update without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
// Without kb.write → 403 on delete
w = h.request("DELETE", "/api/v1/knowledge-bases/"+kbID, userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("kb delete without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
// Without kb.write → 403 on rebuild
w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/rebuild", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("kb rebuild without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "kb.write")
// With kb.write → should pass middleware
w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
"name": "renamed-ok",
})
if w.Code == http.StatusForbidden {
t.Fatalf("kb update WITH kb.write: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §5 persona.create, persona.manage
// ══════════════════════════════════════════════
func TestPermEnforce_PersonaCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Enable user personas policy
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Without persona.create → 403
w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "blocked-persona",
})
if w.Code != http.StatusForbidden {
t.Fatalf("persona create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "persona.create")
w = h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "allowed-persona",
})
if w.Code == http.StatusForbidden {
t.Fatalf("persona create WITH permission: still got 403: %s", w.Body.String())
}
}
func TestPermEnforce_PersonaManage(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
// Grant create so user can make a persona
grantPermission(t, h, adminToken, userID, "persona.create")
w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
"name": "manage-test",
})
var persona map[string]interface{}
decode(w, &persona)
personaID := persona["id"].(string)
// Without persona.manage → 403 on update
w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
"name": "updated",
})
if w.Code != http.StatusForbidden {
t.Fatalf("persona update without persona.manage: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "persona.manage")
w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
"name": "updated-ok",
})
if w.Code == http.StatusForbidden {
t.Fatalf("persona update WITH persona.manage: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §6 workflow.create
// ══════════════════════════════════════════════
func TestPermEnforce_WorkflowCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without workflow.create → 403
w := h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
"name": "blocked-wf",
})
if w.Code != http.StatusForbidden {
t.Fatalf("workflow create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "workflow.create")
w = h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
"name": "allowed-wf",
})
if w.Code == http.StatusForbidden {
t.Fatalf("workflow create WITH permission: still got 403: %s", w.Body.String())
}
}
// ══════════════════════════════════════════════
// §7 task.create
// ══════════════════════════════════════════════
func TestPermEnforce_TaskCreate(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
// Without task.create → 403
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "blocked-task",
"task_type": "prompt",
"schedule": "@daily",
"user_prompt": "test",
"model_id": "test-model",
})
if w.Code != http.StatusForbidden {
t.Fatalf("task create without permission: want 403, got %d: %s", w.Code, w.Body.String())
}
grantPermission(t, h, adminToken, userID, "task.create")
w = h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "allowed-task",
"task_type": "prompt",
"schedule": "@daily",
"user_prompt": "test",
"model_id": "test-model",
"timezone": "UTC",
})
if w.Code != http.StatusCreated {
t.Fatalf("task create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
}
}
// ══════════════════════════════════════════════
// §8 GET /profile/permissions (new endpoint)
// ══════════════════════════════════════════════
func TestPermEnforce_ProfilePermissions_NoGroups(t *testing.T) {
h := setupHarness(t)
_, userToken := seedNonAdminUser(t)
// User with no groups, no Everyone group → empty permissions
w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, ok := resp["permissions"].([]interface{})
if !ok {
t.Fatal("response must have 'permissions' array")
}
if len(perms) != 0 {
t.Fatalf("expected 0 permissions (no groups), got %d: %v", len(perms), perms)
}
groups, ok := resp["groups"].([]interface{})
if !ok {
t.Fatal("response must have 'groups' array")
}
// Should still include Everyone group ID (even though it doesn't exist after truncate)
if len(groups) == 0 {
t.Fatal("groups should include Everyone group ID")
}
}
func TestPermEnforce_ProfilePermissions_WithGrant(t *testing.T) {
h := setupHarness(t)
userID, userToken := seedNonAdminUser(t)
_, adminToken := seedAdminUser(t)
grantPermission(t, h, adminToken, userID, "channel.create")
grantPermission(t, h, adminToken, userID, "model.use")
w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, _ := resp["permissions"].([]interface{})
permSet := make(map[string]bool)
for _, p := range perms {
permSet[p.(string)] = true
}
if !permSet["channel.create"] {
t.Fatal("expected channel.create in permissions")
}
if !permSet["model.use"] {
t.Fatal("expected model.use in permissions")
}
if permSet["task.create"] {
t.Fatal("task.create should NOT be in permissions (not granted)")
}
}
func TestPermEnforce_ProfilePermissions_Admin(t *testing.T) {
h := setupHarness(t)
_, adminToken := seedAdminUser(t)
w := h.request("GET", "/api/v1/profile/permissions", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("profile/permissions admin: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
perms, _ := resp["permissions"].([]interface{})
// Admin should get ALL permissions
if len(perms) != len(allPermissionConstants()) {
t.Fatalf("admin should have all %d permissions, got %d", len(allPermissionConstants()), len(perms))
}
}
// allPermissionConstants returns the count of auth.AllPermissions.
// Duplicated here to avoid import cycle issues in test assertions.
func allPermissionConstants() []string {
return []string{
"model.use", "model.select_any",
"kb.read", "kb.write", "kb.create",
"channel.create", "channel.invite",
"persona.create", "persona.manage",
"workflow.create",
"admin.view",
"token.unlimited",
"task.create", "task.admin", "task.action", "task.starlark",
}
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"chat-switchboard/auth"
"chat-switchboard/store"
)
// ProfilePermissionsHandler exposes the current user's resolved permissions.
type ProfilePermissionsHandler struct {
stores store.Stores
}
func NewProfilePermissionsHandler(s store.Stores) *ProfilePermissionsHandler {
return &ProfilePermissionsHandler{stores: s}
}
// GetMyPermissions returns the current user's effective permission set.
// GET /api/v1/profile/permissions
func (h *ProfilePermissionsHandler) GetMyPermissions(c *gin.Context) {
userID := getUserID(c)
role, _ := c.Get("role")
ctx := c.Request.Context()
// Admin gets all permissions by definition.
var list []string
if role == "admin" {
list = make([]string, len(auth.AllPermissions))
copy(list, auth.AllPermissions)
} else {
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return
}
list = make([]string, 0, len(perms))
for p := range perms {
list = append(list, p)
}
}
sort.Strings(list)
// Contributing groups
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
if groupIDs == nil {
groupIDs = []string{}
}
groupIDs = append(groupIDs, auth.EveryoneGroupID)
// Teams
teams, _ := h.stores.Teams.ListForUser(ctx, userID)
teamData := make([]gin.H, 0, len(teams))
for _, t := range teams {
teamData = append(teamData, gin.H{
"id": t.ID,
"name": t.Name,
"my_role": t.MyRole,
})
}
// Policies that affect UI gating
policies := make(map[string]bool)
if ps := h.stores.Policies; ps != nil {
policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok")
policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas")
policies["allow_raw_model_access"], _ = ps.GetBool(ctx, "allow_raw_model_access")
policies["kb_direct_access"], _ = ps.GetBool(ctx, "kb_direct_access")
}
c.JSON(http.StatusOK, gin.H{
"permissions": list,
"groups": groupIDs,
"teams": teamData,
"policies": policies,
})
}

View File

@@ -761,7 +761,7 @@ func main() {
comp.SetRoutingEvaluator(routing.NewEvaluator())
comp.SetFilterChain(filterChain)
comp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
protected.POST("/chat/completions", comp.Complete)
protected.POST("/chat/completions", middleware.RequirePermission(auth.PermModelUse, stores), comp.Complete)
protected.GET("/tools", comp.ListTools)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
@@ -781,11 +781,11 @@ func main() {
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
protected.POST("/channels/:id/summarize", summarize.Summarize)
protected.POST("/channels/:id/summarize", middleware.RequirePermission(auth.PermModelUse, stores), summarize.Summarize)
// Auto-title generation (utility role)
titleH := handlers.NewTitleHandler(stores, roleResolver)
protected.POST("/channels/:id/generate-title", titleH.GenerateTitle)
protected.POST("/channels/:id/generate-title", middleware.RequirePermission(auth.PermModelUse, stores), titleH.GenerateTitle)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
@@ -820,6 +820,10 @@ func main() {
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Permission bootstrap (v0.37.1) — self-service resolved permissions
permH := handlers.NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions)
// Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
@@ -964,14 +968,14 @@ func main() {
protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB)
protected.GET("/knowledge-bases", kbH.ListKBs)
protected.GET("/knowledge-bases/:id", kbH.GetKB)
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteKB)
protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument)
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument)
protected.POST("/knowledge-bases/:id/search", kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteDocument)
protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(auth.PermKBRead, stores), kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0