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
9 changes: 9 additions & 0 deletions agentteams-controller/internal/auth/authorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ func (a *Authorizer) authorizeHuman(caller *CallerIdentity, req AuthzRequest) er
if req.Action == ActionGet || req.Action == ActionList {
return nil // handler filters by accessibleTeams
}
// L2 humans may update workers within their accessibleTeams scope
// (self-service skill / MCP configuration). The middleware cannot
// resolve worker -> team, so requireSameTeam short-circuits on an
// empty ResourceTeam; the UpdateWorker handler enforces the real
// boundary (team scope + field whitelist), matching the W-PR-2
// project-write pattern.
if req.Action == ActionUpdate {
return a.requireSameTeam(caller, req)
}
return deny(caller, req)

default:
Expand Down
16 changes: 11 additions & 5 deletions agentteams-controller/internal/auth/authorizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ func TestAuthorizer_ManagerAllowsEverything(t *testing.T) {
}
}

// TestAuthorizer_HumanReadOnly guards the L2 security boundary: an L2 human
// TestAuthorizer_HumanScoped guards the L2 security boundary: an L2 human
// (RoleHuman) may read projects/teams/workers in scope, may update projects in
// scope (W-PR-2: pause/resume/replan/lifecycle, code-level requireSameTeam),
// but must NOT manage workers, refresh credentials, or mutate teams.
func TestAuthorizer_HumanReadOnly(t *testing.T) {
// scope (pause/resume/replan/lifecycle, code-level requireSameTeam), and may
// update workers in scope (self-service skill / MCP config — the middleware
// cannot resolve worker -> team, so the UpdateWorker handler enforces the real
// boundary). They must NOT create/delete workers, wake/sleep them, refresh
// credentials, or mutate teams.
func TestAuthorizer_HumanScoped(t *testing.T) {
az := NewAuthorizer()
caller := &CallerIdentity{Role: RoleHuman, Username: "maizong", Teams: []string{"market-team"}}

Expand All @@ -39,6 +42,8 @@ func TestAuthorizer_HumanReadOnly(t *testing.T) {
{Action: ActionGet, ResourceKind: "team"},
{Action: ActionList, ResourceKind: "worker"},
{Action: ActionGet, ResourceKind: "worker"},
{Action: ActionUpdate, ResourceKind: "worker", ResourceTeam: "market-team"},
{Action: ActionUpdate, ResourceKind: "worker"},
{Action: ActionGet, ResourceKind: "status"},
}
for _, req := range allowed {
Expand All @@ -49,7 +54,8 @@ func TestAuthorizer_HumanReadOnly(t *testing.T) {

denied := []AuthzRequest{
{Action: ActionCreate, ResourceKind: "worker"},
{Action: ActionUpdate, ResourceKind: "worker"},
{Action: ActionUpdate, ResourceKind: "worker", ResourceTeam: "another-team"},
{Action: ActionDelete, ResourceKind: "worker"},
{Action: ActionWake, ResourceKind: "worker"},
{Action: ActionSleep, ResourceKind: "worker"},
{Action: ActionRefreshMatrixToken, ResourceKind: "credentials"},
Expand Down
99 changes: 99 additions & 0 deletions agentteams-controller/internal/server/resource_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1"
Expand Down Expand Up @@ -213,6 +214,12 @@ func (h *ResourceHandler) UpdateWorker(w http.ResponseWriter, r *http.Request) {
}

ctx := r.Context()
if caller := authpkg.CallerFromContext(ctx); caller != nil && caller.Role == authpkg.RoleHuman {
if status, msg := h.checkHumanWorkerUpdate(ctx, caller, name, &req); status != 0 {
httputil.WriteError(w, status, msg)
return
}
}
for attempt := 0; attempt < k8sUpdateMaxRetries; attempt++ {
var worker v1beta1.Worker
if err := h.client.Get(ctx, client.ObjectKey{Name: name, Namespace: h.namespace}, &worker); err != nil {
Expand Down Expand Up @@ -247,6 +254,9 @@ func (h *ResourceHandler) UpdateWorker(w http.ResponseWriter, r *http.Request) {
if req.Skills != nil {
worker.Spec.Skills = req.Skills
}
if req.RemoteSkills != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L2-controlled remoteSkills / mcpServers reach the worker verbatim. Note that agentconfig.GenerateMcporterConfig (internal/agentconfig/mcporter.go) uses each MCP server URL as-is and unconditionally injects Authorization: Bearer <gatewayKey> — the same consumer key used for LLM access. With this PR an L2 human can register an MCP entry pointing at an arbitrary external URL, so the worker would deliver the gateway consumer key to an attacker-controlled endpoint (credential exfiltration). This matches the maintainer's review: bearer attachment must be restricted to trusted gateway endpoints or moved to an explicit credentialRef, and arbitrary remote skill registries should require an elevated (FullAccess-like) permission.

worker.Spec.RemoteSkills = req.RemoteSkills
}
if req.McpServers != nil {
worker.Spec.McpServers = req.McpServers
}
Expand Down Expand Up @@ -876,6 +886,95 @@ func (h *ResourceHandler) findTeamForMember(ctx context.Context, name string) (s
return team.Name, true, nil
}

// checkHumanWorkerUpdate enforces the L2 human boundary on worker updates.
// The worker must be a member of one of the caller's accessibleTeams —
// standalone workers are hidden from L2 readers (ListWorkers), so they are
// hidden here as well (404 keeps the endpoint probe-resistant). The request
// may only touch the public-catalog skill assignment (skills). remoteSkills
// (arbitrary external registries with credential-bearing source URIs) and
// mcpServers (the gateway consumer key is injected into every entry, so an
// L2-controlled URL is a credential-exfiltration path) require an elevated
// capability pending the L2 permission design; everything else (model,
// image, identity, resources, ...) is the team owner's domain.
// TestL2WorkerUpdateFieldPolicyCoversAllRequestFields pins the policy so no
// field of UpdateWorkerRequest becomes L2-writable by omission.
// Returns (0, "") when the update is allowed.
func (h *ResourceHandler) checkHumanWorkerUpdate(ctx context.Context, caller *authpkg.CallerIdentity, name string, req *UpdateWorkerRequest) (int, string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field policy here is fail-open: it enumerates the forbidden fields, so any field added to UpdateWorkerRequest in the future becomes L2-writable by default until someone remembers to extend this list. Consider a reflection-based test that asserts every non-whitelisted field of UpdateWorkerRequest appears in the forbidden list (or invert the check to enumerate the allowed fields), so the security boundary degrades to deny-by-default when the request type grows.

team, _, ok, err := findTeamMember(ctx, h.client, h.namespace, name)
if err != nil {
return http.StatusInternalServerError, "lookup worker team: " + err.Error()
}
if !ok {
return http.StatusNotFound, "worker: not found"
}
// Out-of-scope workers are hidden from L2 readers on the read path
// (GET → 404, LIST → filtered). The update path must not reopen that
// probe surface: a 403 here would let a scoped human enumerate workers
// it cannot see and learn which team owns them (W8).
if !caller.TeamMatches(team.Name) {
return http.StatusNotFound, "worker: not found"
}
var forbidden []string
if req.WorkerName != "" {
forbidden = append(forbidden, "workerName")
}
if req.Model != "" {
forbidden = append(forbidden, "model")
}
if req.ModelProvider != "" {
forbidden = append(forbidden, "modelProvider")
}
if req.Runtime != "" {
forbidden = append(forbidden, "runtime")
}
if req.Image != "" {
forbidden = append(forbidden, "image")
}
if req.Identity != "" {
forbidden = append(forbidden, "identity")
}
if req.Soul != "" {
forbidden = append(forbidden, "soul")
}
if req.Agents != "" {
forbidden = append(forbidden, "agents")
}
// Credential-bearing surfaces: remoteSkills (registry source URIs may
// embed tokens) and mcpServers (GenerateMcporterConfig injects the
// gateway bearer key into every entry, URL used verbatim — an
// attacker-controlled URL exfiltrates it). Elevated capability pending
// the L2 permission design.
if req.RemoteSkills != nil {
forbidden = append(forbidden, "remoteSkills")
}
if req.McpServers != nil {
forbidden = append(forbidden, "mcpServers")
}
if req.Package != "" {
forbidden = append(forbidden, "package")
}
if req.Expose != nil {
forbidden = append(forbidden, "expose")
}
if req.ChannelPolicy != nil {
forbidden = append(forbidden, "channelPolicy")
}
if req.Resources != nil {
forbidden = append(forbidden, "resources")
}
if req.ContainerManaged != nil {
forbidden = append(forbidden, "containerManaged")
}
if req.State != nil {
forbidden = append(forbidden, "state")
}
if len(forbidden) > 0 {
return http.StatusBadRequest,
"L2 humans may only update the skills field (public-catalog assignment); remoteSkills and mcpServers require an elevated capability; not allowed: " + strings.Join(forbidden, ", ")
}
return 0, ""
}

func (h *ResourceHandler) validateTeamWorkerMembers(ctx context.Context, teamName string, members []v1beta1.TeamWorkerRef) error {
seen := make(map[string]struct{}, len(members))
leaders := 0
Expand Down
Loading
Loading