Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions agentteams-controller/internal/auth/authorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
ActionSleep Action = "sleep"
ActionEnsureReady Action = "ensure-ready"
ActionReady Action = "ready"
ActionWorkerApproval Action = "worker-approval"
ActionSTS Action = "sts"
ActionStatus Action = "status"
ActionRefreshMatrixToken Action = "refresh-matrix-token"
Expand Down Expand Up @@ -114,6 +115,20 @@ func (a *Authorizer) authorizeHuman(caller *CallerIdentity, req AuthzRequest) er
if req.Action == ActionUpdate {
return a.requireSameTeam(caller, req)
}
if req.Action == ActionWorkerApproval {
// L2 humans may change the tool-approval level of workers in
// their own teams. Like ActionGet this action is NOT rejected
// cross-team at the authorizer level: a 403 here would let a
// scoped caller probe which workers exist in other teams
// (W8 anti-probing). All real enforcement happens in the
// HANDLER, not in this authorizer/middleware:
// ApprovalHandler.approvalScope performs the worker→team
// resolution, hides cross-team and standalone workers as
// 404, and denies team leaders (read-only). The authorizer
// deliberately allows so the handler can hide with 404
// instead of the authorizer answering 403.
return nil
}
return deny(caller, req)

default:
Expand Down
31 changes: 31 additions & 0 deletions agentteams-controller/internal/auth/authorizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,34 @@ func TestAuthorizer_WorkerProjectDenied(t *testing.T) {
}
}
}

func TestAuthorizer_WorkerApproval_W8Boundary(t *testing.T) {
az := NewAuthorizer()
human := &CallerIdentity{Role: RoleHuman, Username: "maizong", Teams: []string{"market-team"}}

// W8: like ActionGet, the approval write is allowed at the authorizer
// even cross-team, so the handler can hide it as 404 — a 403 from the
// middleware would let a scoped caller probe which workers exist in
// other teams. The handler is the real boundary.
for _, req := range []AuthzRequest{
{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst", ResourceTeam: "market-team"},
{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "biz-analyst", ResourceTeam: "biz-team"},
} {
if err := az.Authorize(human, req); err != nil {
t.Errorf("L2 human approval write %s/%s should be allowed at the authorizer (handler hides cross-team as 404), got: %v", req.ResourceName, req.ResourceTeam, err)
}
}

// The leader is read-only on the approval API: the write action is
// denied (their reads still go through ActionGet).
leader := &CallerIdentity{Role: RoleTeamLeader, Username: "market-analyst", Team: "market-team"}
if err := az.Authorize(leader, AuthzRequest{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst", ResourceTeam: "market-team"}); err == nil {
t.Error("team-leader approval write should be denied (read-only)")
}

// Workers never expose the approval action on their own resources.
worker := &CallerIdentity{Role: RoleWorker, Username: "market-analyst", WorkerName: "market-analyst"}
if err := az.Authorize(worker, AuthzRequest{Action: ActionWorkerApproval, ResourceKind: "worker", ResourceName: "market-analyst"}); err == nil {
t.Error("worker self approval write should be denied")
}
}
5 changes: 5 additions & 0 deletions agentteams-controller/internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ func NewHTTPServer(addr string, deps ServerDeps) *HTTPServer {
ckh := NewCheckpointHandler(deps.Client, deps.Namespace, deps.KubeMode, deps.ContainerPrefix)
mux.Handle("GET /api/v1/workers/{name}/checkpoints/{sub}", mw.RequireAuthz(authpkg.ActionGet, "worker", nameFn)(http.HandlerFunc(ckh.proxyCheckpoint)))

// --- Worker tool approval (team-scoped; proxy to the worker's qwenpaw app) ---
ah := NewApprovalHandler(deps.Client, deps.Namespace, deps.KubeMode, deps.ContainerPrefix)
mux.Handle("GET /api/v1/workers/{name}/approval", mw.RequireAuthz(authpkg.ActionGet, "worker", nameFn)(http.HandlerFunc(ah.getWorkerApproval)))
mux.Handle("PUT /api/v1/workers/{name}/approval", mw.RequireAuthz(authpkg.ActionWorkerApproval, "worker", nameFn)(http.HandlerFunc(ah.updateWorkerApproval)))

// W-PR-2: human intervention + lifecycle (write endpoints). All writes go
// through RequireAuthz ActionUpdate + "project" so the authorizer's
// requireSameTeam (TeamLeader / L2) rejects cross-team writes at the code
Expand Down
Loading
Loading