This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/perm_enforcement_test.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

552 lines
20 KiB
Go

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"
"switchboard-core/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",
}
}