agent-exec transport 362d13b3c56f54861bc1cae9cd38cc36/2b675cb19c4f

This commit is contained in:
agent-exec-transport
2026-10-21 15:01:35 +00:00
parent 76abebe790
commit f10fb2e65c
3 changed files with 99 additions and 46 deletions
+72 -36
View File
@@ -480,47 +480,99 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
func TestRolePermissionPreviewRequiresPermissionViewOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
admin := bootSuperAdmin(t, h, st)
// Preview is a permission-management read: system.permissions.view is
// required, and holding system.roles.view alone must not unlock it.
roleViewer := grantSystemActionsFixture(t, h, st, admin, "preview-role-viewer", "system.roles.view")
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"预览职位"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
body := `{"roles":[{"roleId":"` + role.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/preview", body, roleViewer); rec.Code != http.StatusForbidden {
t.Fatalf("role viewer previewed permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", body, admin); rec.Code != http.StatusOK {
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
t.Fatalf("preview body: %s", rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/preview",
`{"roles":[{"roleId":"unknown","expectedRoleVersion":1,"changes":[]}]}`, admin); rec.Code != http.StatusBadRequest &&
rec.Code != http.StatusInternalServerError {
t.Fatalf("empty-changes preview: %d %s", rec.Code, rec.Body.String())
// Preview judges every item separately, so an unknown role is reported
// inside that item's preview instead of failing the whole read.
var unknown struct {
Previews []struct {
RoleID string `json:"roleId"`
VersionMatches bool `json:"versionMatches"`
Error string `json:"error"`
ErrorActionID string `json:"errorActionId"`
} `json:"previews"`
}
unknownRec := do(h, "POST", "/api/admin/roles/permissions/preview",
`{"roles":[{"roleId":"unknown","expectedRoleVersion":1,"changes":[]}]}`, admin)
if unknownRec.Code != http.StatusOK {
t.Fatalf("unknown-role preview: %d %s", unknownRec.Code, unknownRec.Body.String())
}
if err := json.Unmarshal(unknownRec.Body.Bytes(), &unknown); err != nil || len(unknown.Previews) != 1 {
t.Fatalf("decode unknown preview: %v body=%s", err, unknownRec.Body.String())
}
// store's roleAssignmentRoleInvalid reason ("角色不存在或已停用"):
// preview reports an unusable role per item instead of failing the read.
const roleInvalidReason = "角色不存在或已停用"
if unknown.Previews[0].RoleID != "unknown" || unknown.Previews[0].VersionMatches ||
unknown.Previews[0].Error != roleInvalidReason || unknown.Previews[0].ErrorActionID != "" {
t.Fatalf("unknown preview item: %+v", unknown.Previews[0])
}
// An empty item list is still a malformed request.
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", `{"roles":[]}`, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("empty preview: %d %s", rec.Code, rec.Body.String())
}
}
func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
first := createRoleHTTP(t, h, st, admin, "批量职位一")
second := createRoleHTTP(t, h, st, admin, "批量职位二")
admin := bootSuperAdmin(t, h, st)
// Batch commit is a permission-management write: system.permissions.manage
// is required, roles.view/manage alone is not enough.
roleManager := grantSystemActionsFixture(t, h, st, admin, "batch-role-manager", "system.roles.view", "system.roles.manage")
first := createRoleHTTP(t, h, admin, "批量职位一")
second := createRoleHTTP(t, h, admin, "批量职位二")
body := `{"idempotencyKey":"batch-1","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}` +
`]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/batch", body, roleManager); rec.Code != http.StatusForbidden {
t.Fatalf("role manager patched permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", body, admin); rec.Code != http.StatusOK {
t.Fatalf("batch denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "receipts") {
t.Fatalf("batch body: %s", rec.Body.String())
}
body = `{"idempotencyKey":"batch-bad","roles":[` +
// A stale expectedRoleVersion in any item rejects the whole batch with
// 409 Conflict (main's role-version class) and writes nothing at all.
stale := `{"idempotencyKey":"batch-stale","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"system.users.view","allowed":true,"capAllowed":true}]}` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", body, admin); rec.Code != http.StatusInternalServerError &&
rec.Code != http.StatusBadRequest {
t.Fatalf("bad batch response: %d %s", rec.Code, rec.Body.String())
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", stale, admin); rec.Code != http.StatusConflict {
t.Fatalf("stale batch: %d %s", rec.Code, rec.Body.String())
}
for _, role := range []roleHTTPView{first, second} {
rec := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
view := decodeRoleProjectAccess(t, rec)
if view.RoleVersion != 2 || !view.ProjectAccess || len(view.Permissions) != 1 {
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
}
}
// A change outside project access is an invalid policy: 400 Bad Request.
invalid := `{"idempotencyKey":"batch-invalid","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"system.users.view","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", invalid, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("invalid batch: %d %s", rec.Code, rec.Body.String())
}
}
@@ -536,7 +588,7 @@ func TestBatchRoleAssignmentPreviewAndCommitOverHTTP(t *testing.T) {
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, st, admin, "职位预览")
role := createRoleHTTP(t, h, admin, "职位预览")
preview := `{"assignments":[` +
`{"userId":"` + alice.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `},` +
`{"userId":"` + bob.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(bob.StateVersion, 10) + `}` +
@@ -565,7 +617,7 @@ func TestCopyAssignRoleOverHTTP(t *testing.T) {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
source := createRoleHTTP(t, h, st, admin, "源职位")
source := createRoleHTTP(t, h, admin, "源职位")
enable := do(h, "PUT", "/api/admin/roles/"+source.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"copy-source","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if enable.Code != http.StatusOK {
@@ -591,7 +643,7 @@ func TestRoleMembersFilterBeforePagingAndBindCursor(t *testing.T) {
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, st, admin, "成员职位")
role := createRoleHTTP(t, h, admin, "成员职位")
assign := func(userID string, version int64, key string) {
t.Helper()
rec := do(h, "PUT", "/api/admin/users/"+userID+"/role",
@@ -621,7 +673,7 @@ func TestAccountSearchIncludesRoleProjection(t *testing.T) {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role := createRoleHTTP(t, h, st, admin, "账户目录职位")
role := createRoleHTTP(t, h, admin, "账户目录职位")
if rec := do(h, "PUT", "/api/admin/users/"+alice.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(alice.StateVersion, 10)+`,"idempotencyKey":"search-assign"}`, admin); rec.Code != http.StatusOK {
t.Fatalf("assign: %d %s", rec.Code, rec.Body.String())
@@ -633,25 +685,9 @@ func TestAccountSearchIncludesRoleProjection(t *testing.T) {
}
}
func decodeRoleHTTP(t *testing.T, rec *httptest.ResponseRecorder) struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
} {
t.Helper()
var role struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &role); err != nil {
t.Fatalf("decode role: %v body=%s", err, rec.Body.String())
}
return role
}
func createRoleHTTP(t *testing.T, h http.Handler, st *store.Store, admin, name string) struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
} {
// createRoleHTTP creates a role over HTTP and returns the decoded view. It
// reuses roles_test.go's decodeRoleHTTP so the package keeps one decoder.
func createRoleHTTP(t *testing.T, h http.Handler, admin, name string) roleHTTPView {
t.Helper()
rec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if rec.Code != http.StatusOK {
+12 -6
View File
@@ -2387,11 +2387,12 @@ func (s *Store) CreateRoleCopyAndAssign(actor string, source RoleCopySource, spe
VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, name, normalizeRoleName(name), description, true, 1, actorAccount.ID, actorAccount.ID, now, now); err != nil {
return err
}
writes := make([]RolePermissionChange, 0, len(merged))
for _, row := range merged {
writes = append(writes, row)
}
if err := s.upsertRolePermissionsTx(tx, actorAccount.ID, roleID, map[string]RolePermissionChange{}, writes, now); err != nil {
// The role row was inserted in this same transaction, so the copy has
// no prior permission state: current is nil and every entry of the
// merged target is a fresh grant. That is what the original branch
// passed too (nil current against a freshly written role); faking a
// non-empty prior state here would misreport the helper's delta.
if err := s.upsertRolePermissionsTx(tx, actorAccount.ID, roleID, nil, merged, now); err != nil {
return err
}
for i := range items {
@@ -2415,8 +2416,13 @@ func (s *Store) CreateRoleCopyAndAssign(actor string, source RoleCopySource, spe
if err != nil {
return err
}
// ProjectAccess is the normalized projection every other role
// read path fills in (RoleAdmin, ListRoles); the copy receipt must
// report the same value or a copied project.create role reads as
// access-less.
out = RoleCopyAssignResult{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1,
Role: RoleAdminView{RoleRecord: role, BindingCount: len(bindings), Permissions: permissions},
Role: RoleAdminView{RoleRecord: role, BindingCount: len(bindings),
ProjectAccess: projectAccessOf(permissions), Permissions: permissions},
Bindings: bindings}
return s.saveReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, out)
})
+15 -4
View File
@@ -1028,22 +1028,29 @@ func TestAssignUserRolesBatchRejectsWholeBatch(t *testing.T) {
roleID string
previous string
}{
{alice.ID, initialOne, alice.ID, roleOne.RoleID},
{bob.ID, initialTwo, bob.ID, roleTwo.RoleID},
{alice.ID, initialOne, roleOne.RoleID, alice.ID},
{bob.ID, initialTwo, roleTwo.RoleID, bob.ID},
} {
view, err := st.RoleForUser(check.userID)
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != check.roleID {
t.Fatalf("user %s rebound across rollback: %+v err=%v", check.userID, view, err)
}
// The rolled-back batch also leaves the earlier binding record
// itself untouched: same binding id and user state version.
if view.Binding.BindingID != check.binding.BindingID || view.Binding.UserStateVersion != check.binding.UserStateVersion {
t.Fatalf("user %s binding rewritten across rollback: got %+v want %+v", check.userID, view.Binding, check.binding)
}
_ = check.previous
}
}
// TestPreviewUserRoleAssignmentsJudgesEachItem returns one preview per
// input; mismatches carry reason text without writing rows or receipts.
// A batch names each user at most once (validateRoleAssignmentItems), so
// the three judgements are asked of three distinct users.
func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
_, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "预览分配"})
if err != nil {
t.Fatal(err)
@@ -1052,10 +1059,11 @@ func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
epochBefore, _ := st.AuthorityEpoch()
previews, err := st.PreviewUserRoleAssignments("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion + 99},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
{UserID: "user_missing", RoleID: role.RoleID, ExpectedStateVersion: 1},
})
if err != nil {
@@ -1073,6 +1081,9 @@ func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
if previews[2].Error != roleAssignmentUserMissing {
t.Fatalf("missing user: %+v", previews[2])
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("preview advanced epoch: before=%d after=%d", epochBefore, epoch)
}
// The commit's first item still passes; rebinding still works.
if _, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion}},