diff --git a/agentteams-controller/internal/auth/authorizer.go b/agentteams-controller/internal/auth/authorizer.go index 3ddab1361..441ead89a 100644 --- a/agentteams-controller/internal/auth/authorizer.go +++ b/agentteams-controller/internal/auth/authorizer.go @@ -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: diff --git a/agentteams-controller/internal/auth/authorizer_test.go b/agentteams-controller/internal/auth/authorizer_test.go index d30a594c8..e6c2ec6e3 100644 --- a/agentteams-controller/internal/auth/authorizer_test.go +++ b/agentteams-controller/internal/auth/authorizer_test.go @@ -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"}} @@ -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 { @@ -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"}, diff --git a/agentteams-controller/internal/server/resource_handler.go b/agentteams-controller/internal/server/resource_handler.go index ff905ecea..bfbc010db 100644 --- a/agentteams-controller/internal/server/resource_handler.go +++ b/agentteams-controller/internal/server/resource_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" @@ -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 { @@ -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 { + worker.Spec.RemoteSkills = req.RemoteSkills + } if req.McpServers != nil { worker.Spec.McpServers = req.McpServers } @@ -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) { + 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 diff --git a/agentteams-controller/internal/server/resource_handler_l2_update_test.go b/agentteams-controller/internal/server/resource_handler_l2_update_test.go new file mode 100644 index 000000000..3ca488f16 --- /dev/null +++ b/agentteams-controller/internal/server/resource_handler_l2_update_test.go @@ -0,0 +1,237 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + v1beta1 "github.com/agentscope-ai/AgentTeams/agentteams-controller/api/v1beta1" + authpkg "github.com/agentscope-ai/AgentTeams/agentteams-controller/internal/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// newL2UpdateRig builds a handler with team "alpha-team" (leader + worker) +// and a standalone worker "solo-dev". +func newL2UpdateRig(t *testing.T) (*ResourceHandler, *v1beta1.Worker) { + t.Helper() + scheme := newServerTestScheme(t) + team := &v1beta1.Team{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha-team", Namespace: "default"}, + Spec: v1beta1.TeamSpec{WorkerMembers: []v1beta1.TeamWorkerRef{ + {Name: "alpha-lead", Role: "team_leader"}, + {Name: "alpha-dev", Role: "worker"}, + }}, + } + worker := &v1beta1.Worker{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha-dev", Namespace: "default"}, + Spec: v1beta1.WorkerSpec{Model: "qwen3.5-plus"}, + } + solo := &v1beta1.Worker{ + ObjectMeta: metav1.ObjectMeta{Name: "solo-dev", Namespace: "default"}, + Spec: v1beta1.WorkerSpec{Model: "qwen3.5-plus"}, + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(team, worker, solo).Build() + return NewResourceHandler(k8sClient, "default", nil, ""), worker +} + +func l2UpdateRequest(t *testing.T, handler *ResourceHandler, name string, body string, caller *authpkg.CallerIdentity) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/v1/workers/"+name, bytes.NewReader([]byte(body))) + req.SetPathValue("name", name) + req = req.WithContext(context.WithValue(req.Context(), authpkg.CallerKeyForTest(), caller)) + rec := httptest.NewRecorder() + handler.UpdateWorker(rec, req) + return rec +} + +// An L2 human may update the public-catalog skill assignment (skills) on a +// worker in one of their accessibleTeams. +func TestUpdateWorker_L2HumanInScopeSkillFieldsAllowed(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + body := `{"skills":["file-sync","mcporter"]}` + rec := l2UpdateRequest(t, handler, "alpha-dev", body, caller) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp WorkerResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(resp.Skills) != 2 || resp.Skills[0] != "file-sync" { + t.Errorf("skills not applied, got %v", resp.Skills) + } +} + +// Credential-bearing surfaces are closed to default L2: remoteSkills (registry +// source URIs may embed tokens) and mcpServers (the gateway bearer key is +// injected into every entry verbatim — an attacker-controlled URL exfiltrates +// it) require an elevated capability. +func TestUpdateWorker_L2HumanCredentialSurfacesRejected(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + for _, tc := range []struct{ name, body string }{ + {"remoteSkills", `{"remoteSkills":[{"source":"nacos","skills":[{"name":"web-research"}]}]}`}, + {"mcpServers", `{"mcpServers":[{"name":"fetch","url":"https://attacker.example/mcp"}]}`}, + } { + rec := l2UpdateRequest(t, handler, "alpha-dev", tc.body, caller) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: expected 400, got %d: %s", tc.name, rec.Code, rec.Body.String()) + continue + } + if !bytes.Contains(rec.Body.Bytes(), []byte(tc.name)) { + t.Errorf("%s: error should name the field, got: %s", tc.name, rec.Body.String()) + } + } +} + +// TestL2WorkerUpdateFieldPolicyCoversAllRequestFields is the deny-by-default +// pin for the L2 field policy: every field of UpdateWorkerRequest is probed +// with a single-field request; only `skills` may be accepted. If a new field +// is added to the request type without an explicit policy decision in +// checkHumanWorkerUpdate, the probe gets 200 (fail-open) and this test fails. +func TestL2WorkerUpdateFieldPolicyCoversAllRequestFields(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + typ := reflect.TypeOf(UpdateWorkerRequest{}) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.Anonymous { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + // Minimal non-zero probe per field kind: strings get a value, slices + // and pointers get a zero-valued element/object (non-nil). + var probe string + switch f.Type.Kind() { + case reflect.String: + probe = fmt.Sprintf(`{"%s":"x"}`, name) + case reflect.Slice, reflect.Array: + if f.Type.Elem().Kind() == reflect.String { + probe = fmt.Sprintf(`{"%s":["x"]}`, name) + } else { + probe = fmt.Sprintf(`{"%s":[{}]}`, name) + } + case reflect.Ptr: + probe = fmt.Sprintf(`{"%s":{}}`, name) + default: + t.Fatalf("field %s: unsupported kind %s for probe", name, f.Type.Kind()) + } + rec := l2UpdateRequest(t, handler, "alpha-dev", probe, caller) + if name == "skills" { + if rec.Code != http.StatusOK { + t.Errorf("skills: expected 200 (allowed), got %d: %s", rec.Code, rec.Body.String()) + } + continue + } + if rec.Code != http.StatusBadRequest { + t.Errorf("field %s: expected 400 (L2 must not be able to write it), got %d: %s — fail-open policy gap", name, rec.Code, rec.Body.String()) + continue + } + if !bytes.Contains(rec.Body.Bytes(), []byte(name)) { + t.Errorf("field %s: 400 should name the offending field, got: %s", name, rec.Body.String()) + } + } +} + +// Cross-team L2 update is hidden (404) at the handler boundary, not denied +// (403): a 403 would let a scoped human enumerate workers it cannot see on +// the read path and learn their owning team (W8 probe resistance). +func TestUpdateWorker_L2HumanCrossTeamHidden(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "sunzong", Teams: []string{"beta-team"}} + + rec := l2UpdateRequest(t, handler, "alpha-dev", `{"skills":["file-sync"]}`, caller) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// Standalone workers are hidden from L2 readers, so the update path hides +// them too (404, probe-resistant). +func TestUpdateWorker_L2HumanStandaloneWorkerHidden(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + rec := l2UpdateRequest(t, handler, "solo-dev", `{"skills":["file-sync"]}`, caller) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// L2 humans touching owner-domain fields are rejected with 400 naming the +// offending fields. +func TestUpdateWorker_L2HumanForbiddenFieldsRejected(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + rec := l2UpdateRequest(t, handler, "alpha-dev", `{"model":"qwen3.8","soul":"override"}`, caller) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("model")) || !bytes.Contains(rec.Body.Bytes(), []byte("soul")) { + t.Errorf("error should name offending fields, got: %s", rec.Body.String()) + } +} + +// An empty L2 update body is a harmless no-op. +func TestUpdateWorker_L2HumanEmptyBodyNoOp(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "maizong", Teams: []string{"alpha-team"}} + + rec := l2UpdateRequest(t, handler, "alpha-dev", `{}`, caller) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// L1 admins keep full update rights (regression). +func TestUpdateWorker_AdminFullUpdateUnchanged(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleAdmin, Username: "admin"} + + body := `{"model":"qwen3.8","image":"reg.example/qwenpaw:latest","skills":["file-sync"]}` + rec := l2UpdateRequest(t, handler, "alpha-dev", body, caller) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// Team leaders keep in-scope full updates (regression — no L2 gate applies). +func TestUpdateWorker_TeamLeaderInScopeUnchanged(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleTeamLeader, Username: "alpha-lead", Team: "alpha-team"} + + body := `{"model":"qwen3.8","state":"Sleeping"}` + rec := l2UpdateRequest(t, handler, "alpha-dev", body, caller) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +// An L2 human with no accessibleTeams cannot update any worker. In production +// the middleware rejects the teamless caller first (403); the handler hides +// the out-of-scope worker with 404, same as any other out-of-scope case. +func TestUpdateWorker_L2HumanNoTeamsHidden(t *testing.T) { + handler, _ := newL2UpdateRig(t) + caller := &authpkg.CallerIdentity{Role: authpkg.RoleHuman, Username: "luo", Teams: nil} + + rec := l2UpdateRequest(t, handler, "alpha-dev", `{"skills":["file-sync"]}`, caller) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/agentteams-controller/internal/server/types.go b/agentteams-controller/internal/server/types.go index a28c98a34..100793389 100644 --- a/agentteams-controller/internal/server/types.go +++ b/agentteams-controller/internal/server/types.go @@ -15,6 +15,7 @@ type CreateWorkerRequest struct { Soul string `json:"soul,omitempty"` Agents string `json:"agents,omitempty"` Skills []string `json:"skills,omitempty"` + RemoteSkills []v1beta1.RemoteSkillSource `json:"remoteSkills,omitempty"` McpServers []v1beta1.MCPServer `json:"mcpServers,omitempty"` Package string `json:"package,omitempty"` Expose []v1beta1.ExposePort `json:"expose,omitempty"` @@ -39,6 +40,7 @@ type UpdateWorkerRequest struct { Soul string `json:"soul,omitempty"` Agents string `json:"agents,omitempty"` Skills []string `json:"skills,omitempty"` + RemoteSkills []v1beta1.RemoteSkillSource `json:"remoteSkills,omitempty"` McpServers []v1beta1.MCPServer `json:"mcpServers,omitempty"` Package string `json:"package,omitempty"` Expose []v1beta1.ExposePort `json:"expose,omitempty"` @@ -182,6 +184,7 @@ type CreateManagerRequest struct { Soul string `json:"soul,omitempty"` Agents string `json:"agents,omitempty"` Skills []string `json:"skills,omitempty"` + RemoteSkills []v1beta1.RemoteSkillSource `json:"remoteSkills,omitempty"` McpServers []v1beta1.MCPServer `json:"mcpServers,omitempty"` Package string `json:"package,omitempty"` Config *v1beta1.ManagerConfig `json:"config,omitempty"` @@ -197,6 +200,7 @@ type UpdateManagerRequest struct { Soul string `json:"soul,omitempty"` Agents string `json:"agents,omitempty"` Skills []string `json:"skills,omitempty"` + RemoteSkills []v1beta1.RemoteSkillSource `json:"remoteSkills,omitempty"` McpServers []v1beta1.MCPServer `json:"mcpServers,omitempty"` Package string `json:"package,omitempty"` Config *v1beta1.ManagerConfig `json:"config,omitempty"` diff --git a/docs/design/l2-worker-scoped-write.md b/docs/design/l2-worker-scoped-write.md new file mode 100644 index 000000000..b1ece30b7 --- /dev/null +++ b/docs/design/l2-worker-scoped-write.md @@ -0,0 +1,98 @@ +# L2 Human Worker-Scoped Write + +Status: implemented +API: `PUT /api/v1/workers/{name}` (existing endpoint, new caller class) + +## Problem + +L2 humans (`Human` CR with `permissionLevel: 2`, authenticated with their +Matrix token) can read the teams and workers in their `accessibleTeams` scope, +and (since the project write endpoints landed) they can create and drive +projects in that scope. Worker configuration, however, was admin/leader-only: +`authorizeHuman` denied every worker action except `get`/`list`. + +A team-scoped human who owns a dedicated team therefore cannot adjust the +capabilities of the workers they coordinate — enabling a built-in skill, a +remote skill from the source registry, or an MCP server — without escalating +to the admin. Every such change round-trips through a human operator and a +`PUT /api/v1/workers/{name}` call with an admin token. + +## Design + +Extend the existing `PUT /api/v1/workers/{name}` endpoint with a +code-level boundary for `RoleHuman` callers. The same pattern is used by the +project write endpoints: the middleware cannot resolve `worker -> team`, so +`requireSameTeam` in the authorizer is a pass-through for the scoped +request, and the handler enforces the real boundary after resolving the +worker's team. + +Two rules, enforced in `ResourceHandler.checkHumanWorkerUpdate`: + +1. **Team scope.** The worker must be a member of one of the caller's + `accessibleTeams` (resolved via the same team-membership lookup the list + endpoints use). Standalone workers (no team membership) are hidden from + L2 readers in `GET /api/v1/workers`; the update path hides them the same + way and returns `404` so the endpoint stays probe-resistant. Cross-team + updates return `404` for the same reason — a `403` would let a scoped + human enumerate workers it cannot see and learn their owning team. Only + a teamless human (no `accessibleTeams` at all) is rejected with `403` at + the middleware, before any worker lookup. +2. **Field whitelist.** A default L2 update may only set `skills` + (public-catalog assignment). `remoteSkills` (registry source URIs may + embed credentials) and `mcpServers` (the gateway bearer key is injected + into every entry verbatim — an L2-controlled URL would exfiltrate it) are + closed to default L2 pending the elevated-capability design; any other + field present in the body (`model`, `modelProvider`, `runtime`, `image`, + `identity`, `soul`, `agents`, `package`, `expose`, `channelPolicy`, + `resources`, `containerManaged`, `state`) is rejected with `400` naming + the offending fields. Ownership, persona, image, network, and lifecycle + remain the team owner's domain. A full-request-type probe test + (`TestL2WorkerUpdateFieldPolicyCoversAllRequestFields`) pins the policy: + every field of `UpdateWorkerRequest` must be explicitly decided, so no + field can become L2-writable by omission (deny-by-default). + +Semantics for allowed fields are unchanged: merge-patch, non-empty (or +non-nil) wins, conflict-retry loop as for all updates. The request type +gains `remoteSkills` (previously unreadable through the API even for admins, +although the CRD and the deployer already support it); admins may set it, +default L2 may not (see the whitelist above). + +## Contract + +| Caller | `PUT /api/v1/workers/{name}` | +|--------|------------------------------| +| admin / manager | full update, unchanged | +| team leader | all workers, all fields, unchanged (the leader path is not team-scoped in the current code) | +| L2 human (default) | in-team workers only; `skills` only (public-catalog assignment); `remoteSkills` / `mcpServers` 400 (elevated capability pending design); `404` standalone and cross-team (probe-resistant), `400` off-whitelist field | +| worker / other | denied by the authorizer (unchanged) | + +The authorizer change is deliberately minimal: `ActionUpdate` on `worker` +for `RoleHuman` now returns `requireSameTeam` (pass-through when +`ResourceTeam` is empty) instead of `deny`. The handler is the single +enforcement point — the same layering the project endpoints use — so the +scope and whitelist cannot be bypassed by any caller that authenticates as +an L2 human. + +## Out of scope + +- `DELETE`/`POST` for workers (team membership changes stay admin/leader). +- Wake/sleep lifecycle for L2 humans (separate decision). +- Team-scoping the leader update path (the current code lets a team leader + update any worker; pre-existing, out of scope here). +- Standalone-worker access via `accessibleWorkers` (read path does not + expose standalone workers to L2 humans either; keep parity). +- Propagating the update to a running worker container — the existing + reconcile machinery already applies `spec` changes. + +## Tests + +- `internal/auth/authorizer_test.go` — `TestAuthorizer_HumanScoped`: + in-scope and empty-team `ActionUpdate` on `worker` allowed, cross-team + denied, create/delete/wake/sleep still denied. +- `internal/server/resource_handler_l2_update_test.go` — handler boundary: + in-scope skills update applies (200), credential-bearing surfaces + (`remoteSkills` / `mcpServers`) 400 for default L2, cross-team 404, + standalone 404, off-whitelist fields 400 (named), empty body no-op 200, + admin full update unchanged, team leader update unchanged, teamless human + hidden (404; the middleware rejects it first), full-request-type + field-policy probe (every field probed, only `skills` may pass). diff --git a/docs/usage/resource-management.md b/docs/usage/resource-management.md index 927d29950..a69c66b21 100644 --- a/docs/usage/resource-management.md +++ b/docs/usage/resource-management.md @@ -179,6 +179,18 @@ When the Controller receives a Worker resource, it executes: **Status fields (subset):** `status.observedGeneration`, `status.matrixUserID`, `status.roomID`, `status.containerState`, `status.lastHeartbeat`, `status.message`, `status.exposedPorts` (per-port `domain` after expose). +### Worker Updates by Role (API) + +`PUT /api/v1/workers/{name}` is a merge-patch: only fields present in the body are changed. What each role may update: + +| Role | Scope | Fields | +|------|-------|--------| +| admin / manager | any worker | all fields | +| team leader | workers in their team | all fields | +| L2 human (`permissionLevel: 2`) | workers in `accessibleTeams` only | `skills` | + +L2 humans manage the built-in skill assignments of the workers they coordinate without escalating to the admin. `remoteSkills` and `mcpServers` are not L2-writable yet: the MCP path can exfiltrate the gateway consumer key (the generator attaches `Authorization: Bearer ` to every MCP entry), so both fields return `400` for L2 callers until the elevated-capability design lands (see the L2 permission design issue #1220). Other fields — `model`, `image`, `soul`, `agents`, `runtime`, `package`, `expose`, `channelPolicy`, `resources`, `containerManaged`, `state` — stay in the team owner's domain; a body that touches them is rejected with `400`. Out-of-scope workers (cross-team or standalone) are invisible to L2 humans on both read and update (`404`), keeping the endpoint probe-resistant. See [L2 Human Worker-Scoped Write](../design/l2-worker-scoped-write.md). + ## Team A Team is AgentTeams's collaboration unit, consisting of one Team Leader and one or more Team Workers. The Manager delegates tasks to the Team Leader, who handles decomposition, assignment, and aggregation — achieving team-level autonomy. diff --git a/docs/zh-cn/usage/resource-management.md b/docs/zh-cn/usage/resource-management.md index 0c238420f..80e3ea8b7 100644 --- a/docs/zh-cn/usage/resource-management.md +++ b/docs/zh-cn/usage/resource-management.md @@ -179,6 +179,18 @@ spec: **状态字段(节选):** `observedGeneration`、`matrixUserID`、`roomID`、`containerState`、`lastHeartbeat`、`message`、`exposedPorts`(暴露端口及域名)。 +### Worker 更新权限(按角色) + +`PUT /api/v1/workers/{name}` 是合并补丁:body 里出现的字段才会被修改。各角色可更新的范围: + +| 角色 | 范围 | 字段 | +|------|------|------| +| admin / manager | 任意 Worker | 全部字段 | +| 团队 Leader | 本团队 Worker | 全部字段 | +| L2 人类用户(`permissionLevel: 2`) | 仅 `accessibleTeams` 内的 Worker | `skills` | + +L2 人类用户可自主管理所协调 Worker 的内置技能分配,无需升级到 admin。`remoteSkills` 与 `mcpServers` 暂不开放给 L2:MCP 路径存在网关消费者密钥泄露风险(生成器会把 `Authorization: Bearer ` 附加到每个 MCP 条目的 URL 上),因此这两个字段对 L2 调用方返回 `400`,直到提权能力设计落地(见 L2 权限设计 issue #1220)。其余字段——`model`、`image`、`soul`、`agents`、`runtime`、`package`、`expose`、`channelPolicy`、`resources`、`containerManaged`、`state`——仍属团队所有者的权限范围;body 触碰即 `400` 拒绝。越权 Worker(跨团队或独立)对 L2 用户读写均不可见(`404`),端点保持防探测。设计细节见 [L2 Human Worker-Scoped Write](../design/l2-worker-scoped-write.md)。 + ## Team Team 是 AgentTeams 的协作单元,由一个 Team Leader 和若干 Team Worker 组成。Manager 将任务委派给 Team Leader,Leader 负责分解、分配和汇总,实现团队内部自治。