agent-exec transport 64b951970cf4308149cee90d2ab93fbb/078c7847c409

This commit is contained in:
agent-exec-transport
2026-10-21 14:41:59 +00:00
parent 2d1e5f5b16
commit 76abebe790
7 changed files with 2118 additions and 0 deletions
+16
View File
@@ -54,3 +54,19 @@ func TestCatalogReturnsIndependentCopies(t *testing.T) {
t.Fatal("Catalog exposed mutable registry storage")
}
}
// TestCatalogLinkedActionsStayProjectScopedAndUnprotected pins the narrowing
// that the ported batch writers enforce: project.create remains the single
// role authority, and the linked own-project management actions stay
// project-scoped and never protected so the role cannot inherit them.
func TestCatalogLinkedActionsStayProjectScopedAndUnprotected(t *testing.T) {
for _, actionID := range ProjectCreateLinkedActions() {
descriptor, ok := DescriptorByID(actionID)
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected {
t.Fatalf("linked %s descriptor=%+v ok=%v", actionID, descriptor, ok)
}
if IsProjectCreateLinkedAction(ProjectAccessAction) {
t.Fatal("project.create reported as a linked action of itself")
}
}
}
+126
View File
@@ -194,6 +194,24 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
for _, account := range page {
items = append(items, accountProfileJSON(account))
}
if s.systemActionAllowed(actor, "system.roles.view") {
userIDs := make([]string, 0, len(page))
for _, account := range page {
userIDs = append(userIDs, account.ID)
}
roles, err := s.st.ActiveRolesForUsers(userIDs)
if err != nil {
writeStoreErr(w, err)
return
}
for i, account := range page {
var role *store.UserRoleSummary
if found, ok := roles[account.ID]; ok {
role = &found
}
items[i]["role"] = role
}
}
next := ""
if more {
last := page[len(page)-1]
@@ -202,3 +220,111 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
}
// listRoleMembers filters a role's active members before paging them in owner
// order. The signed cursor is bound to the role, the filter, and the account
// directory revision so it cannot be replayed across roles or parameters.
func (s *Server) listRoleMembers(w http.ResponseWriter, r *http.Request) {
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
return
}
actor, ok := s.requireSystemAction(w, r, "system.users.view")
if !ok {
return
}
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
params := r.URL.Query()
query := strings.TrimSpace(params.Get("query"))
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
for _, ch := range query {
if unicode.IsControl(ch) {
http.Error(w, "invalid account search query", http.StatusBadRequest)
return
}
}
mode := params.Get("mode")
if mode == "" {
mode = "fuzzy"
}
if mode != "exact" && mode != "fuzzy" {
http.Error(w, "unknown account search mode", http.StatusBadRequest)
return
}
status := params.Get("status")
if status == "" {
status = "all"
}
if status != "all" && status != "enabled" && status != "disabled" {
http.Error(w, "unknown account status filter", http.StatusBadRequest)
return
}
limit := 20
if raw := params.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
http.Error(w, "invalid account search limit", http.StatusBadRequest)
return
}
limit = parsed
}
roleID := r.PathValue("roleId")
members, revision, err := s.st.ListRoleMembersForActor(actor, roleID)
if err != nil {
writeStoreErr(w, err)
return
}
visibleRevision := sha256.Sum256([]byte(revision.Private))
fingerprint, _ := json.Marshal(struct {
Kind, Actor, RoleID, Query, Mode, Status, Revision string
}{"role-members", actor, roleID, query, mode, status, hex.EncodeToString(visibleRevision[:])})
digest := sha256.Sum256(fingerprint)
version := hex.EncodeToString(digest[:])
var after adminSearchCursor
if token := params.Get("cursor"); token != "" {
var valid bool
after, valid = s.parseAdminCursor(token, version)
if !valid {
http.Error(w, "role member cursor expired or invalid", http.StatusConflict)
return
}
}
page := make([]store.RoleMember, 0, limit+1)
matches := func(value string) bool {
if query == "" {
return true
}
if mode == "exact" {
return strings.EqualFold(value, query)
}
return strings.Contains(strings.ToLower(value), strings.ToLower(query))
}
for _, member := range members {
if status == "enabled" && !member.Enabled || status == "disabled" && member.Enabled {
continue
}
if after.Owner != "" && (member.Owner < after.Owner || member.Owner == after.Owner && member.UserID <= after.UserID) {
continue
}
if query != "" && !matches(member.Owner) && !matches(member.Name) && !matches(member.UserID) {
continue
}
page = append(page, member)
if len(page) > limit {
break
}
}
next := ""
if len(page) > limit {
page = page[:limit]
last := page[len(page)-1]
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.UserID})
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"members": page, "nextCursor": next})
}
+190
View File
@@ -469,3 +469,193 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
t.Fatalf("legacy true,true row denied project access: %d %s", rec.Code, rec.Body.String())
}
}
// =====================================================================
// HTTP-level coverage for the ported IAM batch endpoints (port from
// feature/iam-perm-ux-backend). The endpoints reuse the existing auth
// gates (system.permissions.* and system.roles.*) and main's narrowed role
// model (only project.create is role authority).
// =====================================================================
func TestRolePermissionPreviewRequiresPermissionViewOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
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 := 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())
}
}
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, "批量职位二")
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 := 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":[` +
`{"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}]}` +
`]}`
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())
}
}
func TestBatchRoleAssignmentPreviewAndCommitOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, st, 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) + `}` +
`]}`
if rec := do(h, "POST", "/api/admin/users/role-assignments/preview", preview, admin); rec.Code != http.StatusOK {
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), alice.ID) {
t.Fatalf("preview missing alice: %s", rec.Body.String())
}
commit := `{"idempotencyKey":"assign-batch","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) + `}` +
`]}`
if rec := do(h, "POST", "/api/admin/users/role-assignments", commit, admin); rec.Code != http.StatusOK {
t.Fatalf("commit denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "bindings") {
t.Fatalf("commit body: %s", rec.Body.String())
}
}
func TestCopyAssignRoleOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
source := createRoleHTTP(t, h, st, 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 {
t.Fatalf("enable source: %d %s", enable.Code, enable.Body.String())
}
body := `{"source":{"roleId":"` + source.RoleID + `","expectedRoleVersion":2},"name":"复制职位","description":"","idempotencyKey":"copy-1","assignments":[{"userId":"` + alice.ID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `}]}`
if rec := do(h, "POST", "/api/admin/roles/copy-assign", body, admin); rec.Code != http.StatusOK {
t.Fatalf("copy denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "复制职位") {
t.Fatalf("copy body: %s", rec.Body.String())
}
}
func TestRoleMembersFilterBeforePagingAndBindCursor(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
bob, _ := st.GetAccountByOwner("bob")
role := createRoleHTTP(t, h, st, admin, "成员职位")
assign := func(userID string, version int64, key string) {
t.Helper()
rec := do(h, "PUT", "/api/admin/users/"+userID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(version, 10)+`,"idempotencyKey":"`+key+`"}`, admin)
if rec.Code != http.StatusOK {
t.Fatalf("assign %s: %d %s", userID, rec.Code, rec.Body.String())
}
}
assign(alice.ID, alice.StateVersion, "assign-alice")
assign(bob.ID, bob.StateVersion, "assign-bob")
if rec := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members", "", admin); rec.Code != http.StatusOK {
t.Fatalf("list members: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), alice.ID) || !strings.Contains(rec.Body.String(), bob.ID) {
t.Fatalf("members=%s", rec.Body.String())
}
filtered := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members?query=alice&mode=exact", "", admin)
if filtered.Code != http.StatusOK || !strings.Contains(filtered.Body.String(), alice.ID) || strings.Contains(filtered.Body.String(), bob.ID) {
t.Fatalf("filter=%d %s", filtered.Code, filtered.Body.String())
}
}
func TestAccountSearchIncludesRoleProjection(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role := createRoleHTTP(t, h, st, 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())
}
if rec := do(h, "GET", "/api/admin/users/search?query=alice&mode=exact", "", admin); rec.Code != http.StatusOK {
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
t.Fatalf("search missing role projection: %s", rec.Body.String())
}
}
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"`
} {
t.Helper()
rec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if rec.Code != http.StatusOK {
t.Fatalf("create role %s: %d %s", name, rec.Code, rec.Body.String())
}
return decodeRoleHTTP(t, rec)
}
+162
View File
@@ -31,6 +31,17 @@ func isAccountAdministrationAction(actionID string) bool {
return store.IsAccountAdministrationAction(actionID)
}
// systemActionAllowed reports, without writing a response, whether the actor
// holds a system action. Optional read projections (e.g. role summary in the
// account directory) use it to skip the projection when the caller cannot
// see it, so a missing grant stays invisible rather than being denied loudly.
func (s *Server) systemActionAllowed(actor, actionID string) bool {
if s.Auth == nil || !s.Auth.Enabled {
return true
}
return s.authorizer.AuthorizeSystem(actor, actionID).Allowed
}
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
return
@@ -191,3 +202,154 @@ func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Reque
}
writeJSON(w, http.StatusOK, decision)
}
// listRolePermissionPreview predicts role permission replace/patch effects
// without writing rows or receipts. Viewing is enough to call it; the commit
// revalidates every item.
func (s *Server) listRolePermissionPreview(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.view")
if !ok {
return
}
var req struct {
Roles []store.RolePermissionPreviewInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Roles) == 0 {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
}
previews, err := s.st.PreviewRolePermissions(actor, req.Roles)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
// batchPatchRolePermissions applies an explicit patch to several roles in
// one transaction. The actor must hold system.permissions.manage (R4, R5).
func (s *Server) batchPatchRolePermissions(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
if !ok {
return
}
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Roles []store.RolePermissionPatchInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.IdempotencyKey == "" || len(req.Roles) == 0 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" || role.ExpectedRoleVersion < 1 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
}
batch, err := s.st.PatchRolePermissionsBatch(actor, req.Roles, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"receipts": batch.Receipts})
}
// decodeRoleAssignments parses a role assignment payload shared by the
// batch commit (withKey=true, requires idempotencyKey) and the read-only
// preview (withKey=false).
func decodeRoleAssignments(w http.ResponseWriter, r *http.Request, withKey bool) ([]store.UserRoleAssignmentInput, string, bool) {
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Assignments []store.UserRoleAssignmentInput `json:"assignments"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Assignments) == 0 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
if withKey && req.IdempotencyKey == "" {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
for _, assignment := range req.Assignments {
if assignment.UserID == "" || assignment.RoleID == "" || assignment.ExpectedStateVersion < 1 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
}
return req.Assignments, req.IdempotencyKey, true
}
func (s *Server) assignUserRolesBatch(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
if !ok {
return
}
assignments, key, ok := decodeRoleAssignments(w, r, true)
if !ok {
return
}
batch, err := s.st.AssignUserRolesBatch(actor, assignments, key)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"bindings": batch.Bindings})
}
// previewUserRoleAssignments reads the commit rule for each assignment and
// returns it without writing rows or receipts. Viewing is enough to call it.
func (s *Server) previewUserRoleAssignments(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
actor := s.owner(r)
assignments, _, ok := decodeRoleAssignments(w, r, false)
if !ok {
return
}
previews, err := s.st.PreviewUserRoleAssignments(actor, assignments)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
func (s *Server) copyAssignRole(w http.ResponseWriter, r *http.Request) {
var actor string
for _, actionID := range []string{"system.roles.manage", "system.permissions.manage", "system.roles.assign"} {
var ok bool
if actor, ok = s.requireSystemAction(w, r, actionID); !ok {
return
}
}
var req struct {
Source store.RoleCopySource `json:"source"`
Name string `json:"name"`
Description string `json:"description"`
Overrides []store.RolePermissionChange `json:"overrides"`
Assignments []store.RoleCopyAssignment `json:"assignments"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Source.RoleID == "" || req.Source.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid role copy", http.StatusBadRequest)
return
}
result, err := s.st.CreateRoleCopyAndAssign(actor, req.Source, store.RoleCreate{Name: req.Name, Description: req.Description},
req.Overrides, req.Assignments, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"role": result.Role, "bindings": result.Bindings, "linkedExpansion": result.LinkedExpansion, "receiptId": result.ReceiptID, "authorityEpoch": result.AuthorityEpoch})
}
+6
View File
@@ -320,6 +320,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole)
mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole)
mux.HandleFunc("PUT /api/admin/roles/{roleId}/permissions", s.replaceRolePermissions)
mux.HandleFunc("POST /api/admin/roles/permissions/preview", s.listRolePermissionPreview)
mux.HandleFunc("POST /api/admin/roles/permissions/batch", s.batchPatchRolePermissions)
mux.HandleFunc("POST /api/admin/roles/copy-assign", s.copyAssignRole)
mux.HandleFunc("GET /api/admin/roles/{roleId}/members", s.listRoleMembers)
mux.HandleFunc("POST /api/admin/users/role-assignments", s.assignUserRolesBatch)
mux.HandleFunc("POST /api/admin/users/role-assignments/preview", s.previewUserRoleAssignments)
mux.HandleFunc("GET /api/admin/users/{userId}/role", s.getUserRole)
mux.HandleFunc("PUT /api/admin/users/{userId}/role", s.assignUserRole)
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
File diff suppressed because it is too large Load Diff
+483
View File
@@ -758,3 +758,486 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
t.Fatalf("legacy audit row removed: %d err=%v", legacy, err)
}
}
// =====================================================================
// Tests for the ported batch entry points (feature/iam-perm-ux-backend).
// Each test stays inside main's narrowed role model (only project.create is
// role authority) and reuses the existing account-scoped write fences.
// =====================================================================
// enableRoleOne creates a role with its project.create boolean turned on
// and returns the role after the receipt. The test owns the idempotency
// key so multiple batch steps stay reproducible.
func enableRoleOne(t *testing.T, st *Store, actor, name string) RoleRecord {
t.Helper()
role, err := st.CreateRole(actor, RoleCreate{Name: name})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions(actor, role.RoleID, role.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"enable-"+role.RoleID); err != nil {
t.Fatal(err)
}
current, err := st.GetRole(role.RoleID)
if err != nil {
t.Fatal(err)
}
return current
}
// TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles covers the
// happy path: two roles get their project.create boolean toggled in one
// transaction with one authority epoch step.
func TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "批量职位一")
second := enableRoleOne(t, st, "admin", "批量职位二")
epochBefore, _ := st.AuthorityEpoch()
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
batch, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion, Changes: []RolePermissionChange{disable}},
}, "batch-disable")
if err != nil {
t.Fatal(err)
}
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Receipts) != 2 {
t.Fatalf("batch=%+v epochBefore=%d", batch, epochBefore)
}
for _, receipt := range batch.Receipts {
if receipt.PreviousRoleVersion == receipt.RoleVersion {
t.Fatalf("role_version not advanced: %+v", receipt)
}
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || view.ProjectAccess {
t.Fatalf("role %s still has project access: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch ensures one bad
// item rolls back the whole batch: the surviving role keeps its prior
// state and the authority epoch does not advance.
func TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "回滚职位一")
second := enableRoleOne(t, st, "admin", "回滚职位二")
epochBefore, _ := st.AuthorityEpoch()
bogus := []RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
}
if _, err := st.PatchRolePermissionsBatch("admin", bogus, "batch-bad"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bogus batch accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || !view.ProjectAccess {
t.Fatalf("role %s lost project access after rejection: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchStaleVersion rolls back the batch and
// refuses when one role's expected version mismatches.
func TestPatchRolePermissionsBatchStaleVersion(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
first := enableRoleOne(t, st, "admin", "过期职位一")
second := enableRoleOne(t, st, "admin", "过期职位二")
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
if _, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion + 9, Changes: []RolePermissionChange{disable}},
}, "batch-stale"); !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) {
t.Fatalf("stale batch accepted: %v", err)
}
for _, roleID := range []string{first.RoleID, second.RoleID} {
view, err := st.RoleAdmin(roleID)
if err != nil || !view.ProjectAccess {
t.Fatalf("role %s lost project access: %+v err=%v", roleID, view, err)
}
}
}
// TestPatchRolePermissionsBatchRejectsLimitAndIdempotency enforces the 20-item
// batch ceiling and the idempotency replay contract.
func TestPatchRolePermissionsBatchRejectsLimitAndIdempotency(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "上限职位")
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
oversized := make([]RolePermissionPatchInput, maxRolePermissionBatch+1)
for i := range oversized {
oversized[i] = RolePermissionPatchInput{RoleID: enabled.RoleID, ExpectedRoleVersion: 1, Changes: []RolePermissionChange{disable}}
}
if _, err := st.PatchRolePermissionsBatch("admin", oversized, "oversize"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("oversized batch accepted: %v", err)
}
if _, err := st.PatchRolePermissionsBatch("admin", nil, "empty"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("empty batch accepted: %v", err)
}
first, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
"idempotency-key")
if err != nil {
t.Fatal(err)
}
replay, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
"idempotency-key")
if err != nil || replay.AuthorityEpoch != first.AuthorityEpoch || replay.ReceiptID != first.ReceiptID {
t.Fatalf("replay=%+v first=%+v err=%v", replay, first, err)
}
}
// TestPreviewRolePermissionsMatchesCommitResult keeps the read surface honest:
// the preview's "after" state is exactly what the matching commit produces.
func TestPreviewRolePermissionsMatchesCommitResult(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "预览职位")
previews, err := st.PreviewRolePermissions("admin",
[]RolePermissionPreviewInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}})
if err != nil {
t.Fatal(err)
}
if len(previews) != 1 || previews[0].RoleID != enabled.RoleID || previews[0].VersionMatches != true {
t.Fatalf("preview=%+v", previews)
}
commit, err := st.PatchRolePermissionsBatch("admin",
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}},
"preview-commit")
if err != nil {
t.Fatal(err)
}
if commit.Receipts[0].RoleVersion <= enabled.RoleVersion {
t.Fatalf("commit=%+v", commit)
}
}
// TestPreviewRolePermissionsRejectsInvalidBatchItem returns the item-level
// rejection in the preview without writing, exactly mirroring the commit gate.
func TestPreviewRolePermissionsRejectsInvalidBatchItem(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
enabled := enableRoleOne(t, st, "admin", "预览拒绝职位")
previews, err := st.PreviewRolePermissions("admin",
[]RolePermissionPreviewInput{
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
})
if err != nil {
t.Fatal(err)
}
if len(previews) != 2 || previews[0].Error == "" || previews[0].ErrorActionID != "system.users.view" {
t.Fatalf("previews=%+v", previews)
}
if previews[1].Error != "" {
t.Fatalf("second item reported error without writing: %+v", previews[1])
}
if view, _ := st.RoleAdmin(enabled.RoleID); !view.ProjectAccess {
t.Fatalf("preview mutating role: %+v", view)
}
}
// TestAssignUserRolesBatchRebindsInOneTransaction covers the rebind happy
// path: two users get a new role in one transaction with one authority epoch.
func TestAssignUserRolesBatchRebindsInOneTransaction(t *testing.T) {
st := openTestStore(t)
admin, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "批量职位"})
if err != nil {
t.Fatal(err)
}
epochBefore, _ := st.AuthorityEpoch()
batch, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
}, "assign-batch")
if err != nil {
t.Fatal(err)
}
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Bindings) != 2 {
t.Fatalf("batch=%+v", batch)
}
for _, userID := range []string{alice.ID, bob.ID} {
view, err := st.RoleForUser(userID)
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != role.RoleID {
t.Fatalf("user %s not bound: %+v err=%v", userID, view, err)
}
}
// Replay returns the same receipt.
replay, err := st.AssignUserRolesBatch("admin",
[]UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
}, "assign-batch")
if err != nil || replay.AuthorityEpoch != batch.AuthorityEpoch {
t.Fatalf("replay=%+v err=%v", replay, err)
}
_ = admin
}
// TestAssignUserRolesBatchRejectsWholeBatch ensures a single rejected item
// (stale state) rolls back both rebinds; the prior active binding stays.
func TestAssignUserRolesBatchRejectsWholeBatch(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "职位Alpha"})
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "职位Beta"})
initialOne, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-init")
if err != nil {
t.Fatal(err)
}
initialTwo, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-init")
if err != nil {
t.Fatal(err)
}
epochBefore, _ := st.AuthorityEpoch()
bad := []UserRoleAssignmentInput{
{UserID: alice.ID, RoleID: roleTwo.RoleID, ExpectedStateVersion: initialOne.UserStateVersion},
{UserID: bob.ID, RoleID: roleOne.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
}
if _, err := st.AssignUserRolesBatch("admin", bad, "batch-bad"); !errors.Is(err, ErrConflict) {
t.Fatalf("bad batch accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
}
for _, check := range []struct {
userID string
binding RoleBinding
roleID string
previous string
}{
{alice.ID, initialOne, alice.ID, roleOne.RoleID},
{bob.ID, initialTwo, bob.ID, roleTwo.RoleID},
} {
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)
}
_ = check.previous
}
}
// TestPreviewUserRoleAssignmentsJudgesEachItem returns one preview per
// input; mismatches carry reason text without writing rows or receipts.
func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "预览分配"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
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: "user_missing", RoleID: role.RoleID, ExpectedStateVersion: 1},
})
if err != nil {
t.Fatal(err)
}
if len(previews) != 3 {
t.Fatalf("previews=%+v", previews)
}
if previews[0].Error != "" || previews[0].CurrentRoleID != role.RoleID {
t.Fatalf("fresh match: %+v", previews[0])
}
if previews[1].Error != roleAssignmentStateChanged {
t.Fatalf("stale match: %+v", previews[1])
}
if previews[2].Error != roleAssignmentUserMissing {
t.Fatalf("missing user: %+v", previews[2])
}
// 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}},
"preview-then-commit"); err != nil {
t.Fatalf("commit after preview: %v", err)
}
}
// TestListRoleMembersForActorRequiresGrantsAndPaginates exercises the read
// surface: only callers with system.users.view + system.roles.view see
// members, and active-vs-inactive counts follow the binding table.
func TestListRoleMembersForActorRequiresGrantsAndPaginates(t *testing.T) {
st := openTestStore(t)
admin, alice, bob := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "成员职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", bob.ID, role.RoleID, bob.StateVersion, "assign-bob"); err != nil {
t.Fatal(err)
}
// Re-assigning alice ends her previous binding, leaving bob as the only
// active member for the second assignment.
bobAccount, _ := st.GetAccount(bob.ID)
other, err := st.CreateRole("admin", RoleCreate{Name: "另一职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, other.RoleID,
alice.StateVersion+1, "assign-alice-other"); err != nil {
t.Fatal(err)
}
if _, _, err := st.ListRoleMembersForActor("ordinary", role.RoleID); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("ordinary caller accepted: %v", err)
}
members, _, err := st.ListRoleMembersForActor("admin", role.RoleID)
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].UserID != bobAccount.ID || !members[0].Enabled {
t.Fatalf("members=%+v", members)
}
_ = admin
}
// TestActiveRolesForUsersReturnsActiveBindings only returns the active
// role for each user; an ended binding is absent.
func TestActiveRolesForUsersReturnsActiveBindings(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "现役"})
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "退役"})
if _, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-1"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-2"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, roleTwo.RoleID, alice.StateVersion+1, "assign-alice-2"); err != nil {
t.Fatal(err)
}
roles, err := st.ActiveRolesForUsers([]string{alice.ID, bob.ID, "user_missing"})
if err != nil {
t.Fatal(err)
}
if roles[alice.ID].RoleID != roleTwo.RoleID || roles[bob.ID].RoleID != roleTwo.RoleID {
t.Fatalf("active roles=%+v want alice=%s bob=%s", roles, roleTwo.RoleID, roleTwo.RoleID)
}
if _, present := roles["user_missing"]; present {
t.Fatalf("phantom user active: %+v", roles)
}
}
// TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction is the happy
// path: the copy lands as a new role, picks up any override, and rebinds the
// assigned users in one transaction with one authority epoch step.
func TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", source.RoleID, source.RoleVersion,
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"enable-source"); err != nil {
t.Fatal(err)
}
source, _ = st.GetRole(source.RoleID)
epochBefore, _ := st.AuthorityEpoch()
result, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制职位"},
nil,
[]RoleCopyAssignment{
{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion},
{UserID: bob.ID, ExpectedStateVersion: bob.StateVersion},
}, "copy-1")
if err != nil {
t.Fatal(err)
}
if result.AuthorityEpoch != epochBefore+1 || len(result.Bindings) != 2 || result.Role.RoleID == source.RoleID {
t.Fatalf("result=%+v", result)
}
if !result.Role.ProjectAccess {
t.Fatalf("copy lost project access: %+v", result.Role)
}
for _, userID := range []string{alice.ID, bob.ID} {
view, err := st.RoleForUser(userID)
if err != nil || view.Role == nil || view.Role.RoleID != result.Role.RoleID {
t.Fatalf("user %s not bound to copy: %+v err=%v", userID, view, err)
}
}
}
// TestCreateRoleCopyAndAssignRejectsWithoutResidue keeps the fail-closed
// path: no role row, no bindings, no epoch advance when any item fails.
func TestCreateRoleCopyAndAssignRejectsWithoutResidue(t *testing.T) {
st := openTestStore(t)
_, alice, bob := rolePolicyAccounts(t, st)
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
if err != nil {
t.Fatal(err)
}
source, _ = st.GetRole(source.RoleID)
epochBefore, _ := st.AuthorityEpoch()
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制失败"},
[]RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}},
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
"copy-bad-overrides"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad overrides accepted: %v", err)
}
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
t.Fatalf("rejected copy advanced epoch: before=%d after=%d", epochBefore, epoch)
}
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: ""},
nil,
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
"copy-bad-name"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad name accepted: %v", err)
}
if _, err := st.CreateRoleCopyAndAssign("admin",
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
RoleCreate{Name: "复制失败"},
nil,
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: 0}, {UserID: bob.ID, ExpectedStateVersion: bob.StateVersion}},
"copy-bad-state"); !errors.Is(err, ErrInvalidRolePolicy) {
t.Fatalf("bad state version accepted: %v", err)
}
if _, ok := getRoleByName(st, "复制失败"); ok {
t.Fatalf("rejected copy created a role row")
}
}
func getRoleByName(st *Store, name string) (RoleRecord, bool) {
roles, _, err := st.ListRoles(name, "exact", "all", "", "", 20)
if err != nil || len(roles) == 0 {
return RoleRecord{}, false
}
return roles[0].RoleRecord, true
}