Changeset 0.37.1 (#213)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
551
server/handlers/perm_enforcement_test.go
Normal file
551
server/handlers/perm_enforcement_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
80
server/handlers/profile_permissions.go
Normal file
80
server/handlers/profile_permissions.go
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user