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", } }