Changeset 0.27.4 (#171)

This commit is contained in:
2026-03-11 10:30:12 +00:00
parent a46ec63464
commit 07432233f7
13 changed files with 736 additions and 57 deletions

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
@@ -50,6 +51,33 @@ func (h *TaskHandler) ListAll(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// ListTeamTasks returns tasks scoped to the given team.
// Any team member can view; RequireTeamMember middleware enforces access.
// GET /api/v1/teams/:teamId/tasks
func (h *TaskHandler) ListTeamTasks(c *gin.Context) {
teamID := c.Param("teamId")
tasks, err := h.stores.Tasks.ListByTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team tasks"})
return
}
if tasks == nil {
tasks = []models.Task{}
}
c.JSON(http.StatusOK, gin.H{"data": tasks})
}
// CreateTeamTask creates a task scoped to the team.
// Team admins only (RequireTeamAdmin middleware).
// POST /api/v1/teams/:teamId/tasks
func (h *TaskHandler) CreateTeamTask(c *gin.Context) {
teamID := c.Param("teamId")
// Inject team context, then delegate to Create
c.Set("force_team_id", teamID)
c.Set("force_scope", "team")
h.Create(c)
}
// Create creates a new task.
// POST /api/v1/tasks
func (h *TaskHandler) Create(c *gin.Context) {
@@ -70,6 +98,16 @@ func (h *TaskHandler) Create(c *gin.Context) {
t.OwnerID = c.GetString("user_id")
// v0.27.5: Team task context override (set by CreateTeamTask)
if forceTeam, ok := c.Get("force_team_id"); ok {
teamID := forceTeam.(string)
t.TeamID = &teamID
t.Scope = "team"
}
if forceScope, ok := c.Get("force_scope"); ok {
t.Scope = forceScope.(string)
}
// v0.27.2: Personal task check — non-admin users need tasks.allow_personal
if t.Scope == "personal" || t.Scope == "" {
if c.GetString("role") != "admin" && !taskCfg.AllowPersonal {
@@ -143,6 +181,45 @@ func (h *TaskHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, t)
}
// canAccessTask returns true if the user can view this task.
// Access: owner, system admin, or team member (for team-scoped tasks).
func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool {
if c.GetString("role") == "admin" {
return true
}
if t.OwnerID == c.GetString("user_id") {
return true
}
// Team members can view team-scoped tasks
if t.Scope == "team" && t.TeamID != nil {
var exists bool
database.DB.QueryRow(database.Q(
`SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)`,
), *t.TeamID, c.GetString("user_id")).Scan(&exists)
return exists
}
return false
}
// canMutateTask returns true if the user can edit/delete/run this task.
// Mutation: owner, system admin, or team admin (for team-scoped tasks).
func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool {
if c.GetString("role") == "admin" {
return true
}
if t.OwnerID == c.GetString("user_id") {
return true
}
if t.Scope == "team" && t.TeamID != nil {
var teamRole string
database.DB.QueryRow(database.Q(
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
), *t.TeamID, c.GetString("user_id")).Scan(&teamRole)
return teamRole == "admin"
}
return false
}
// Get returns a single task.
// GET /api/v1/tasks/:id
func (h *TaskHandler) Get(c *gin.Context) {
@@ -152,8 +229,7 @@ func (h *TaskHandler) Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Ownership check (admin bypasses)
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canAccessTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -172,7 +248,7 @@ func (h *TaskHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -220,7 +296,7 @@ func (h *TaskHandler) Delete(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -236,7 +312,20 @@ func (h *TaskHandler) Delete(c *gin.Context) {
// GET /api/v1/tasks/:id/runs
func (h *TaskHandler) ListRuns(c *gin.Context) {
id := c.Param("id")
runs, err := h.stores.Tasks.ListRuns(c.Request.Context(), id, 50)
ctx := c.Request.Context()
// Access check (v0.27.5)
t, err := h.stores.Tasks.GetByID(ctx, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if !h.canAccessTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
runs, err := h.stores.Tasks.ListRuns(ctx, id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"})
return
@@ -265,7 +354,7 @@ func (h *TaskHandler) RunNow(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
@@ -297,7 +386,7 @@ func (h *TaskHandler) KillRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if c.GetString("role") != "admin" && t.OwnerID != c.GetString("user_id") {
if !h.canMutateTask(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}