diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 6efbf86..2a12e8a 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "agent-system-backend/internal/authz/catalog" "agent-system-backend/internal/store" ) @@ -57,7 +58,12 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) { } } -func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) { +// TestProjectCreateIsTheOnlyRolePolicyOverHTTP pins the narrowed role model +// (R2): a role carries the single project.create boolean, a batch naming any +// other action is refused outright, no linked rows are expanded on write, and +// the /api/me own-project link is a catalog projection of that boolean rather +// than something the role stored. +func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() admin := bootAdmin(t, h, st) @@ -74,40 +80,76 @@ func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) { t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String()) } role := decodeRoleHTTP(t, roleRec) - conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", - `{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin) - if conflict.Code != http.StatusBadRequest { - t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String()) + permissions := "/api/admin/roles/" + role.RoleID + "/permissions" + roleView := func() *httptest.ResponseRecorder { + return do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin) } - stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", - `{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin) + const enableCreate = `{"actionId":"project.create","allowed":true,"capAllowed":true}` + const denyLinked = `{"actionId":"project.members.manage","allowed":false,"capAllowed":false}` + + // A batch that enables project.create and explicitly denies a linked + // own-project action is refused as a whole: the linked action is not role + // authority any more, so nothing in that batch is written. + both := do(h, "PUT", permissions, + `{"expectedRoleVersion":1,"idempotencyKey":"create-and-deny-linked","changes":[`+enableCreate+`,`+denyLinked+`]}`, admin) + if both.Code != http.StatusBadRequest { + t.Fatalf("linked deny accepted beside create: %d %s", both.Code, both.Body.String()) + } + stale := do(h, "PUT", permissions, + `{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin) if stale.Code != http.StatusConflict { t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String()) } - policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", - `{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin) - if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) { + policy := do(h, "PUT", permissions, + `{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[`+enableCreate+`]}`, admin) + if policy.Code != http.StatusOK { t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String()) } + var receipt store.PolicyReceipt + if err := json.Unmarshal(policy.Body.Bytes(), &receipt); err != nil { + t.Fatalf("decode receipt: %v body=%s", err, policy.Body.String()) + } + if receipt.ReceiptID == "" || receipt.RoleVersion != 2 || len(receipt.LinkedExpansion) != 0 || + strings.Contains(policy.Body.String(), "linkedExpansion") { + t.Fatalf("receipt expanded linked rows: %+v body=%s", receipt, policy.Body.String()) + } + requireRoleProjectAccess(t, roleView(), 2, true) + + // Split across two submissions the linked deny is refused on its own, so the + // role keeps the project.create policy the first submission wrote. + denyLater := do(h, "PUT", permissions, + `{"expectedRoleVersion":2,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin) + if denyLater.Code != http.StatusBadRequest { + t.Fatalf("linked deny accepted in a later batch: %d %s", denyLater.Code, denyLater.Body.String()) + } + requireRoleProjectAccess(t, roleView(), 2, true) + assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role", `{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin) if assign.Code != http.StatusOK { t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String()) } me := bearerRequest(h, "GET", "/api/me", "", key) - var body struct { + if me.Code != http.StatusOK { + t.Fatalf("me: %d %s", me.Code, me.Body.String()) + } + var identity struct { Linked struct { ProjectCreate bool `json:"projectCreate"` Source string `json:"source"` LinkedActions []string `json:"linkedActions"` + Conflicts []string `json:"conflicts"` } `json:"linkedCapabilities"` } - if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil { - t.Fatalf("me: %d %s", me.Code, me.Body.String()) + if err := json.Unmarshal(me.Body.Bytes(), &identity); err != nil { + t.Fatalf("decode me: %v body=%s", err, me.Body.String()) } - if !body.Linked.ProjectCreate || body.Linked.Source != "role" || - strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" { - t.Fatalf("linked capabilities=%+v", body.Linked) + if !identity.Linked.ProjectCreate || identity.Linked.Source != "role" || len(identity.Linked.Conflicts) != 0 || + strings.Join(identity.Linked.LinkedActions, ",") != strings.Join(catalog.ProjectCreateLinkedActions(), ",") { + t.Fatalf("linked capabilities=%+v", identity.Linked) + } + if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"创建者项目"}`, key); rec.Code != http.StatusOK { + t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String()) } }