From 76abebe790b7e7b3736041825d6c6b1566c0b146 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Wed, 21 Oct 2026 14:41:59 +0000 Subject: [PATCH] agent-exec transport 64b951970cf4308149cee90d2ab93fbb/078c7847c409 --- internal/authz/catalog/catalog_test.go | 16 + internal/server/account_search.go | 126 +++ internal/server/role_authorization_test.go | 190 ++++ internal/server/roles.go | 162 +++ internal/server/server.go | 6 + internal/store/role_policy.go | 1135 ++++++++++++++++++++ internal/store/role_policy_test.go | 483 +++++++++ 7 files changed, 2118 insertions(+) diff --git a/internal/authz/catalog/catalog_test.go b/internal/authz/catalog/catalog_test.go index 34f5ed3..b03a65a 100644 --- a/internal/authz/catalog/catalog_test.go +++ b/internal/authz/catalog/catalog_test.go @@ -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") + } + } +} diff --git a/internal/server/account_search.go b/internal/server/account_search.go index 2e2a0ee..9002449 100644 --- a/internal/server/account_search.go +++ b/internal/server/account_search.go @@ -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}) +} diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 2a12e8a..356f713 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -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) +} diff --git a/internal/server/roles.go b/internal/server/roles.go index e65dddd..5f358b6 100644 --- a/internal/server/roles.go +++ b/internal/server/roles.go @@ -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}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 0013465..a393b84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index aab4e70..b9861bf 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -1,6 +1,7 @@ package store import ( + "context" "crypto/sha256" "database/sql" "encoding/hex" @@ -1377,3 +1378,1137 @@ func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) } return out, tx.Commit() } + +// ===================================================================== +// IAM permission-management batch operations (ported from +// feature/iam-perm-ux-backend). Behaviour follows main's narrowed role +// model: the only role authority is project.create. Other catalog actions +// carry legacy audit rows in role_permissions but never authorize anything, +// so writers reject non-project.create change items the same way +// ReplaceRolePermissions already does (R2). +// ===================================================================== + +// RolePermissionPreviewInput is one role's proposed change set. Mode "replace" +// (the default) treats Changes as the full replace set; "patch" treats them as +// an explicit patch over the role's current rows. +type RolePermissionPreviewInput struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + Mode string `json:"mode,omitempty"` + Changes []RolePermissionChange `json:"changes"` +} + +const ( + RolePermissionPreviewReplace = "replace" + RolePermissionPreviewPatch = "patch" +) + +// RolePermissionStateChange describes one action whose display state differs +// between the role's current rows and the planned-after rows. +type RolePermissionStateChange struct { + ActionID string `json:"actionId"` + Label string `json:"label"` + Before string `json:"before"` + After string `json:"after"` +} + +// RolePermissionConflict names one action that the planned-after rows would +// leave in a state inconsistent with the bound project.create grant. +type RolePermissionConflict struct { + ActionID string `json:"actionId"` + Reason string `json:"reason"` +} + +// RolePermissionPreview is the planned-effect view of one role's change set, +// without writing. Error reports an item-level rejection and never fails the +// whole batch; the commit revalidates every item. +type RolePermissionPreview struct { + RoleID string `json:"roleId"` + RoleName string `json:"roleName"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + CurrentRoleVersion int64 `json:"currentRoleVersion"` + VersionMatches bool `json:"versionMatches"` + Changes []RolePermissionStateChange `json:"changes"` + LinkedExpansion []string `json:"linkedExpansion"` + LinkedExpansionState string `json:"linkedExpansionState,omitempty"` + KeptLinkedActions []string `json:"keptLinkedActions"` + MigrationNote string `json:"migrationNote,omitempty"` + Conflicts []RolePermissionConflict `json:"conflicts"` + Error string `json:"error,omitempty"` + ErrorActionID string `json:"errorActionId,omitempty"` +} + +const ( + permissionStateDenied = "不允许" + permissionStateProject = "按项目授权" + permissionStateDefault = "默认允许" + permissionStateInvalid = "非法组合" + linkedExpansionLinked = "按项目授权 · 仅本人创建的项目" + maxRolePermissionPreviewItem = 100 +) + +// RolePermissionPatchInput is one role's explicit patch. Only listed actions +// change; every unlisted row of the role stays exactly as stored. Unlisted +// legacy audit rows are preserved untouched. +type RolePermissionPatchInput struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + Changes []RolePermissionChange `json:"changes"` +} + +// RolePermissionBatchReceipt is one role's outcome inside a batch patch. All +// roles of a batch share the batch receipt ID and authority epoch. +type RolePermissionBatchReceipt struct { + RoleID string `json:"roleId"` + PolicyReceipt + PreviousRoleVersion int64 `json:"previousRoleVersion"` + Changes []RolePermissionStateChange `json:"changes"` + KeptLinkedActions []string `json:"keptLinkedActions"` +} + +// RolePermissionBatch is the union of every role receipt in one write. +type RolePermissionBatch struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Receipts []RolePermissionBatchReceipt `json:"receipts"` +} + +// UserRoleAssignmentInput is one assignment: replace the user's active +// binding with roleID. ExpectedStateVersion forbids stale state. +type UserRoleAssignmentInput struct { + UserID string `json:"userId"` + RoleID string `json:"roleId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +// UserRoleAssignmentResult is one assignment's outcome inside a batch. +type UserRoleAssignmentResult struct { + RoleBinding + PreviousRoleID string `json:"previousRoleId"` +} + +// UserRoleAssignmentBatch is the union of every assignment receipt in one +// rebind transaction. One batch ⇒ one authority epoch step. +type UserRoleAssignmentBatch struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Bindings []UserRoleAssignmentResult `json:"bindings"` +} + +// UserRoleAssignmentPreview predicts one assignment without writing. It is not +// an authorization token: the commit revalidates every item. +type UserRoleAssignmentPreview struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + CurrentRoleID string `json:"currentRoleId"` + CurrentRoleName string `json:"currentRoleName"` + TargetRoleID string `json:"targetRoleId"` + TargetRoleName string `json:"targetRoleName"` + StateMatches bool `json:"stateMatches"` + Error string `json:"error,omitempty"` +} + +// RoleCopySource is the role a copy-and-assign reads from. +type RoleCopySource struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` +} + +// RoleCopyAssignment is one user the copy should rebind to the new role. +type RoleCopyAssignment struct { + UserID string `json:"userId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +// RoleCopyAssignResult is the outcome of one copy-and-assign transaction. +type RoleCopyAssignResult struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Role RoleAdminView `json:"role"` + LinkedExpansion []string `json:"linkedExpansion"` + Bindings []UserRoleAssignmentResult `json:"bindings"` +} + +// RoleMember is the minimal person view of one active role binding. It is +// what ListRoleMembersForActor returns per row, sorted by owner. +type RoleMember struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + AssignedAt time.Time `json:"assignedAt"` + BindingVersion int64 `json:"bindingVersion"` +} + +// UserRoleSummary is the role side of one active binding returned by the +// account directory: only roles the caller may surface. Empty when the user +// has no active binding. +type UserRoleSummary struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +const ( + maxRolePermissionBatch = 20 + maxRoleAssignmentBatch = 100 + + roleAssignmentUserMissing = "用户不存在" + roleAssignmentUserDisabled = "用户已停用" + roleAssignmentStateChanged = "状态版本已变化" + roleAssignmentRoleInvalid = "角色不存在或已停用" +) + +// rolePermissionChangeError names the batch item that makes a role replace or +// patch set invalid. Reason is user-facing Chinese text without raw IDs. +type rolePermissionChangeError struct { + ActionID string + Reason string +} + +func (e *rolePermissionChangeError) Error() string { + return fmt.Sprintf("%s: %s: %s", ErrInvalidRolePolicy, e.ActionID, e.Reason) +} + +func (e *rolePermissionChangeError) Unwrap() error { return ErrInvalidRolePolicy } + +// actionLabel returns the catalog label for actionID, or the ID itself when +// the action is not registered. +func actionLabel(actionID string) string { + if descriptor, ok := catalog.DescriptorByID(actionID); ok { + return descriptor.Label + } + return actionID +} + +// normalizePermissionChanges validates a role replace/patch set and indexes +// it by action. In main's narrowed role model, only project.create is a role +// authority; other actions are rejected with a user-facing reason so writers +// stay consistent with the existing ReplaceRolePermissions contract. +func normalizePermissionChanges(changes []RolePermissionChange) (map[string]RolePermissionChange, error) { + next := make(map[string]RolePermissionChange, len(changes)) + for _, change := range changes { + descriptor, ok := catalog.DescriptorByID(change.ActionID) + _, duplicate := next[change.ActionID] + reason := "" + switch { + case !ok: + reason = "包含未登记的权限动作" + case descriptor.Protected: + reason = "「" + descriptor.Label + "」是受保护的系统授权,只能按用户单独授予" + case change.ActionID != catalog.ProjectAccessAction: + reason = "「" + descriptor.Label + "」不是角色可授权限,当前角色模型仅对 project.create 授权" + case duplicate: + reason = "「" + descriptor.Label + "」在同一批变更中重复出现" + case change.Allowed && !change.CapAllowed: + reason = "「" + descriptor.Label + "」不能设为默认允许却关闭上限,该组合不合法" + } + if reason != "" { + return nil, &rolePermissionChangeError{ActionID: change.ActionID, Reason: reason} + } + next[change.ActionID] = change + } + return next, nil +} + +// policyWriteBatch is the batch-shaped policy transaction: it locks the actor +// and every target user in owner order, then authority_state, before fn runs. +// The locked rows must still be the enabled users the caller named; fn must +// not lock further users, and policyWriteBatch is not reentrant. +func (s *Store) policyWriteBatch(actor string, targetUserIDs []string, fn func(*sql.Tx, Account, map[string]Account, int64) error) error { + if actor == "" || fn == nil { + return ErrAccountForbidden + } + owners := []string{actor} + ownerByUser := make(map[string]string, len(targetUserIDs)) + for _, userID := range targetUserIDs { + target, err := s.GetAccount(userID) + if err != nil { + return ErrAccountForbidden + } + ownerByUser[userID] = target.Owner + owners = append(owners, target.Owner) + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return ErrAccountForbidden + } + actorAccount := accounts[actor] + if !actorAccount.Enabled { + return ErrAccountForbidden + } + targets := make(map[string]Account, len(ownerByUser)) + for userID, owner := range ownerByUser { + target := accounts[owner] + if !target.Enabled || target.ID != userID { + return ErrAccountForbidden + } + targets[userID] = target + } + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + if err := fn(tx, actorAccount, targets, epoch); err != nil { + return err + } + return tx.Commit() +} + +// capabilityConflict is one linked action an enabled project.create denies. +// UserID is set when a bound project.create grant holder causes it. +type capabilityConflict struct { + RoleID string + UserID string + ActionID string +} + +// conflicts lists, in a stable order, every role that enables project.create +// while denying a linked action, then every project.create grant holder whose +// active role denies one. Read surfaces use this to surface conflicts without +// writing; the commit gate (checkCapabilityAssociationTx) is unchanged. +func (state capabilityAssociation) conflicts() []capabilityConflict { + out := []capabilityConflict{} + roleIDs := make([]string, 0, len(state.roles)) + for roleID := range state.roles { + roleIDs = append(roleIDs, roleID) + } + sort.Strings(roleIDs) + for _, roleID := range roleIDs { + permissions := state.roles[roleID] + if !projectCreateEnabled(permissions) { + continue + } + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(permissions, actionID) { + out = append(out, capabilityConflict{RoleID: roleID, ActionID: actionID}) + } + } + } + userIDs := []string{} + for userID, creator := range state.creators { + if creator { + userIDs = append(userIDs, userID) + } + } + sort.Strings(userIDs) + for _, userID := range userIDs { + roleID, bound := state.bindings[userID] + if !bound { + continue + } + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(state.roles[roleID], actionID) { + out = append(out, capabilityConflict{RoleID: roleID, UserID: userID, ActionID: actionID}) + } + } + } + return out +} + +// capabilityConflictReason renders a human-readable explanation for the +// conflict. Reason text is the same for the preview and the commit gate. +func capabilityConflictReason(conflict capabilityConflict) string { + label := actionLabel(conflict.ActionID) + if conflict.UserID != "" { + return "使用此角色的用户已直接获授创建项目,其本人项目的「" + label + "」资格不能被明确禁止" + } + return "创建项目需要本人项目的「" + label + "」资格,但该资格已被明确禁止" +} + +// rolePermissionQuerier is the read interface used by rolePermissionRecords +// to run inside either *sql.Tx (PreviewRolePermissions) or *sql.DB +// (RolePermissions). +type rolePermissionQuerier interface { + Query(string, ...any) (*sql.Rows, error) +} + +// rolePermissionRecords reads every row of one role's permission table in a +// stable order; the public RolePermissions view still filters the response to +// the project.create row per main's narrowed contract. +func (s *Store) rolePermissionRecords(db rolePermissionQuerier, roleID string) ([]RolePermissionRecord, error) { + rows, err := db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []RolePermissionRecord{} + for rows.Next() { + var permission RolePermissionRecord + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil { + return nil, err + } + out = append(out, permission) + } + return out, rows.Err() +} + +// rolePermissionBitsTx reads every action bit of one role inside one tx. +func (s *Store) rolePermissionBitsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) { + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]RolePermissionChange{} + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + return nil, err + } + out[permission.ActionID] = permission + } + return out, rows.Err() +} + +// checkRoleAssignmentTx judges one assignment against current rows. account +// is nil when the user does not exist. class is the batch-wide error the item +// would cause at commit; reason is its user-facing Chinese text. +func (s *Store) checkRoleAssignmentTx(tx *sql.Tx, account *Account, item UserRoleAssignmentInput, lockRole bool) (roleAssignmentCheck, error) { + var check roleAssignmentCheck + role, err := s.roleTx(tx, item.RoleID, lockRole) + switch { + case err == nil: + check.role, check.roleFound = role, true + case !errors.Is(err, ErrNotFound): + return check, err + } + switch { + case account == nil: + check.reason, check.class = roleAssignmentUserMissing, ErrAccountForbidden + case !account.Enabled: + check.reason, check.class = roleAssignmentUserDisabled, ErrAccountForbidden + case account.StateVersion != item.ExpectedStateVersion: + check.reason, check.class = roleAssignmentStateChanged, ErrConflict + case !check.roleFound || !check.role.Enabled: + check.reason, check.class = roleAssignmentRoleInvalid, ErrInvalidRolePolicy + } + return check, nil +} + +type roleAssignmentCheck struct { + role RoleRecord + roleFound bool + reason string + class error +} + +// assignmentUsers validates batch shape: at most maxRoleAssignmentBatch +// items, each naming a distinct user with a positive expected state version. +func assignmentUsers(items []UserRoleAssignmentInput) ([]string, error) { + if len(items) > maxRoleAssignmentBatch { + return nil, ErrInvalidRolePolicy + } + seen := make(map[string]bool, len(items)) + userIDs := make([]string, 0, len(items)) + for _, item := range items { + if item.UserID == "" || item.ExpectedStateVersion < 1 || seen[item.UserID] { + return nil, ErrInvalidRolePolicy + } + seen[item.UserID] = true + userIDs = append(userIDs, item.UserID) + } + return userIDs, nil +} + +// validateRoleAssignmentItems checks the full assignment shape: every role +// ID present, every user distinct, every state version positive. +func validateRoleAssignmentItems(items []UserRoleAssignmentInput) ([]string, error) { + if len(items) == 0 { + return nil, ErrInvalidRolePolicy + } + for _, item := range items { + if item.RoleID == "" { + return nil, ErrInvalidRolePolicy + } + } + return assignmentUsers(items) +} + +// activeRoleTx reports the given user's currently-active role, or empty +// strings when the user has no active binding. +func (s *Store) activeRoleTx(tx *sql.Tx, userID string) (string, string, error) { + var roleID, name string + err := tx.QueryRow(s.q(`SELECT b.role_id,r.name FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id + WHERE b.user_id=? AND b.active=?`), userID, true).Scan(&roleID, &name) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + return roleID, name, err +} + +// rebindUserRoleTx ends the user's active binding, inserts a new binding, and +// advances the user's state version by one. The user must still be enabled at +// expectedState; otherwise ErrConflict. +func (s *Store) rebindUserRoleTx(tx *sql.Tx, actorID, userID, roleID string, expectedState int64, now string) (string, error) { + if _, err := tx.Exec(s.q(`UPDATE user_role_bindings SET active=?,binding_version=binding_version+1,ended_at=? WHERE user_id=? AND active=?`), false, now, userID, true); err != nil { + return "", err + } + bindingID := newID("binding") + if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), bindingID, userID, roleID, true, 1, actorID, now, ""); err != nil { + return "", err + } + result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`), userID, expectedState, true) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrConflict + } + return bindingID, nil +} + +// assignRolesTx validates every item, then rebinds each user. A rejected +// item rolls the whole batch back; targets are the locked accounts. The +// super-admin target protection and the no-elevation-through-role grant +// checks match AssignUserRole so the batch cannot become an elevation path +// (R4, R5). +func (s *Store) assignRolesTx(tx *sql.Tx, actorAccount Account, targets map[string]Account, items []UserRoleAssignmentInput, now string, epoch int64) ([]UserRoleAssignmentResult, error) { + for _, item := range items { + target, locked := targets[item.UserID] + account := &target + if !locked { + account = nil + } + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return nil, err + } + if !actorAccount.IsSuperAdmin { + grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, item.RoleID) + if err != nil { + return nil, err + } + if grantsAdministration { + return nil, ErrAccountForbidden + } + } + check, err := s.checkRoleAssignmentTx(tx, account, item, true) + if err != nil { + return nil, err + } + if check.class != nil { + return nil, check.class + } + } + assignedAt, _ := time.Parse(time.RFC3339Nano, now) + out := make([]UserRoleAssignmentResult, 0, len(items)) + for _, item := range items { + previousRoleID, _, err := s.activeRoleTx(tx, item.UserID) + if err != nil { + return nil, err + } + bindingID, err := s.rebindUserRoleTx(tx, actorAccount.ID, item.UserID, item.RoleID, item.ExpectedStateVersion, now) + if err != nil { + return nil, err + } + out = append(out, UserRoleAssignmentResult{PreviousRoleID: previousRoleID, RoleBinding: RoleBinding{ + BindingID: bindingID, UserID: item.UserID, RoleID: item.RoleID, Active: true, BindingVersion: 1, + AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: item.ExpectedStateVersion + 1, AuthorityEpoch: epoch + 1}}) + } + return out, nil +} + +// requireEnabledActorTx is the read-mode equivalent of policyWrite's actor +// precheck: the actor's users row must exist and still be enabled. +func (s *Store) requireEnabledActorTx(tx *sql.Tx, actor string) error { + var enabled bool + err := tx.QueryRow(s.q(`SELECT enabled FROM users WHERE owner=?`), actor).Scan(&enabled) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) { + return ErrAccountForbidden + } + return err +} + +// PreviewUserRoleAssignments judges each assignment with the commit's rule +// in one read transaction. It never writes rows, receipts, or the authority +// epoch. +func (s *Store) PreviewUserRoleAssignments(actor string, items []UserRoleAssignmentInput) ([]UserRoleAssignmentPreview, error) { + if actor == "" { + return nil, ErrAccountForbidden + } + if _, err := validateRoleAssignmentItems(items); err != nil { + return nil, err + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if err := s.requireEnabledActorTx(tx, actor); err != nil { + return nil, err + } + out := make([]UserRoleAssignmentPreview, 0, len(items)) + for _, item := range items { + preview := UserRoleAssignmentPreview{UserID: item.UserID, TargetRoleID: item.RoleID} + var account *Account + found, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), item.UserID)) + switch { + case err == nil: + account = &found + preview.Owner, preview.Name = found.Owner, found.Profile.Name + preview.StateMatches = found.StateVersion == item.ExpectedStateVersion + if preview.CurrentRoleID, preview.CurrentRoleName, err = s.activeRoleTx(tx, found.ID); err != nil { + return nil, err + } + case !errors.Is(err, ErrNotFound): + return nil, err + } + check, err := s.checkRoleAssignmentTx(tx, account, item, false) + if err != nil { + return nil, err + } + if check.roleFound { + preview.TargetRoleName = check.role.Name + } + preview.Error = check.reason + out = append(out, preview) + } + return out, nil +} + +// AssignUserRolesBatch rebinds several users in one policy transaction with +// one authority epoch step and one idempotency receipt; any rejected item +// rolls back the whole batch. +func (s *Store) AssignUserRolesBatch(actor string, items []UserRoleAssignmentInput, key string) (UserRoleAssignmentBatch, error) { + userIDs, err := validateRoleAssignmentItems(items) + if err != nil { + return UserRoleAssignmentBatch{}, err + } + requestHash := policyRequestHash(struct{ Items []UserRoleAssignmentInput }{items}) + var out UserRoleAssignmentBatch + err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, &out) + if err != nil || replayed { + return err + } + now := nowISO() + bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch) + if err != nil { + return err + } + if err := s.checkCapabilityAssociationTx(tx, userIDs, nil, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = UserRoleAssignmentBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, Bindings: bindings} + return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, out) + }) + return out, err +} + +// PreviewRolePermissions predicts ReplaceRolePermissions for several roles in +// one read transaction. It never writes rows, receipts, or the authority +// epoch. The commit revalidates every item. +func (s *Store) PreviewRolePermissions(actor string, items []RolePermissionPreviewInput) ([]RolePermissionPreview, error) { + if actor == "" { + return nil, ErrAccountForbidden + } + if len(items) == 0 || len(items) > maxRolePermissionPreviewItem { + return nil, ErrInvalidRolePolicy + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if err := s.requireEnabledActorTx(tx, actor); err != nil { + return nil, err + } + out := make([]RolePermissionPreview, 0, len(items)) + for _, item := range items { + preview, err := s.previewRolePermissionsTx(tx, item) + if err != nil { + return nil, err + } + out = append(out, preview) + } + return out, nil +} + +func (s *Store) previewRolePermissionsTx(tx *sql.Tx, item RolePermissionPreviewInput) (RolePermissionPreview, error) { + switch item.Mode { + case "", RolePermissionPreviewReplace, RolePermissionPreviewPatch: + default: + return RolePermissionPreview{}, ErrInvalidRolePolicy + } + role, err := s.roleTx(tx, item.RoleID, false) + if errors.Is(err, ErrNotFound) { + return RolePermissionPreview{RoleID: item.RoleID, Changes: []RolePermissionStateChange{}, + LinkedExpansion: []string{}, KeptLinkedActions: []string{}, Conflicts: []RolePermissionConflict{}, + Error: roleAssignmentRoleInvalid, ErrorActionID: ""}, nil + } + if err != nil { + return RolePermissionPreview{}, err + } + out := RolePermissionPreview{RoleID: role.RoleID, RoleName: role.Name, ExpectedRoleVersion: item.ExpectedRoleVersion, + CurrentRoleVersion: role.RoleVersion, VersionMatches: role.RoleVersion == item.ExpectedRoleVersion, + Changes: []RolePermissionStateChange{}, LinkedExpansion: []string{}, KeptLinkedActions: []string{}, + Conflicts: []RolePermissionConflict{}} + next, err := normalizePermissionChanges(item.Changes) + var changeErr *rolePermissionChangeError + if errors.As(err, &changeErr) { + out.Error, out.ErrorActionID = changeErr.Reason, changeErr.ActionID + return out, nil + } + if err != nil { + return RolePermissionPreview{}, err + } + current, err := s.rolePermissionBitsTx(tx, role.RoleID) + if err != nil { + return RolePermissionPreview{}, err + } + after := map[string]RolePermissionChange{} + for actionID, row := range current { + after[actionID] = row + } + for actionID, row := range next { + after[actionID] = row + } + wasCreator := projectCreateEnabled(current) + isCreator := projectCreateEnabled(after) + if wasCreator && !isCreator { + kept := keptLinkedRows(current) + out.KeptLinkedActions = append(out.KeptLinkedActions, kept...) + out.MigrationNote = projectCreateDisabledPreviewNote(kept) + } + out.Changes = rolePermissionStateChanges(current, after, nil) + state, err := s.loadCapabilityAssociationTx(tx, nil, []string{role.RoleID}) + if err != nil { + return RolePermissionPreview{}, err + } + state.roles[role.RoleID] = after + for _, conflict := range state.conflicts() { + if conflict.RoleID != role.RoleID { + continue + } + out.Conflicts = append(out.Conflicts, RolePermissionConflict{ + ActionID: conflict.ActionID, Reason: capabilityConflictReason(conflict), + }) + } + return out, nil +} + +// keptLinkedRows lists the linked actions the role already has an explicit +// row for, so disabling project.create keeps them in place. +func keptLinkedRows(current map[string]RolePermissionChange) []string { + kept := []string{} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, existed := current[actionID]; existed { + kept = append(kept, actionID) + } + } + return kept +} + +func projectCreateDisabledPreviewNote(kept []string) string { + if len(kept) == 0 { + return "关闭创建项目后,没有需要保留的本人项目管理资格" + } + labels := make([]string, 0, len(kept)) + for _, actionID := range kept { + labels = append(labels, "「"+actionLabel(actionID)+"」") + } + return "关闭创建项目后,以下既有资格将保留:" + strings.Join(labels, "、") + ";如需收回请明确移除" +} + +// rolePermissionStateChanges lists every action whose display state differs +// between before and after, in catalog order. Linked-expansion rows are +// reported separately and skipped here. +func rolePermissionStateChanges(before, after map[string]RolePermissionChange, expansion []string) []RolePermissionStateChange { + expanded := map[string]bool{} + for _, actionID := range expansion { + expanded[actionID] = true + } + out := []RolePermissionStateChange{} + for _, descriptor := range catalog.Catalog() { + if expanded[descriptor.ActionID] { + continue + } + beforeName, afterName := permissionStateDenied, permissionStateDenied + if row, ok := before[descriptor.ActionID]; ok { + beforeName = permissionStateName(row.Allowed, row.CapAllowed) + } + if row, ok := after[descriptor.ActionID]; ok { + afterName = permissionStateName(row.Allowed, row.CapAllowed) + } + if beforeName != afterName { + out = append(out, RolePermissionStateChange{ActionID: descriptor.ActionID, + Label: descriptor.Label, Before: beforeName, After: afterName}) + } + } + return out +} + +// permissionStateName is the role permission view's user-facing state. +func permissionStateName(allowed, capAllowed bool) string { + switch { + case allowed && capAllowed: + return permissionStateDefault + case !allowed && capAllowed: + return permissionStateProject + case !allowed && !capAllowed: + return permissionStateDenied + } + return permissionStateInvalid +} + +// patchRolePermissionsTx applies an explicit patch to one role: list-only +// rows are upserted, every other row of the role stays exactly as stored. +// Returns a per-role batch receipt; the surrounding transaction is rolled +// back on any error. Only project.create may appear in Changes per main's +// narrowed model; other rows in the role are inert legacy audit data. +func (s *Store) patchRolePermissionsTx(tx *sql.Tx, actorID string, item RolePermissionPatchInput, now string) (RolePermissionBatchReceipt, error) { + role, err := s.roleTx(tx, item.RoleID, true) + if errors.Is(err, ErrNotFound) { + return RolePermissionBatchReceipt{}, ErrRolePolicyRoleMissing + } + if err != nil { + return RolePermissionBatchReceipt{}, err + } + if role.RoleVersion != item.ExpectedRoleVersion { + return RolePermissionBatchReceipt{}, ErrRolePolicyVersion + } + patch, err := normalizePermissionChanges(item.Changes) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + current, err := s.rolePermissionBitsTx(tx, role.RoleID) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + after := map[string]RolePermissionChange{} + for actionID, row := range current { + after[actionID] = row + } + for actionID, row := range patch { + after[actionID] = row + } + if err := s.upsertRolePermissionsTx(tx, actorID, role.RoleID, current, after, now); err != nil { + return RolePermissionBatchReceipt{}, err + } + result, err := tx.Exec(s.q(`UPDATE roles SET role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), actorID, now, role.RoleID, item.ExpectedRoleVersion) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + if n, err := result.RowsAffected(); err != nil { + return RolePermissionBatchReceipt{}, err + } else if n != 1 { + return RolePermissionBatchReceipt{}, ErrRolePolicyVersion + } + wasCreator := projectCreateEnabled(after) + afterPatch := map[string]RolePermissionChange{} + for actionID, row := range after { + afterPatch[actionID] = row + } + for actionID, row := range patch { + afterPatch[actionID] = row + } + isCreator := projectCreateEnabled(afterPatch) + disabling := wasCreator && !isCreator + kept := []string{} + if disabling { + kept = append(kept, keptLinkedRows(current)...) + } + out := RolePermissionBatchReceipt{RoleID: role.RoleID, PreviousRoleVersion: role.RoleVersion, + PolicyReceipt: PolicyReceipt{RoleVersion: role.RoleVersion + 1}, + Changes: rolePermissionStateChanges(current, afterPatch, nil)} + if disabling { + out.KeptLinkedActions = append(out.KeptLinkedActions, kept...) + out.MigrationNote = projectCreateDisabledNote(kept) + } + return out, nil +} + +// upsertRolePermissionsTx inserts or updates rows so that, after it returns, +// every row named in "after" matches its target (allowed, cap_allowed). Rows +// in "current" but not in "after" are left untouched (patch semantics). +func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, current, after map[string]RolePermissionChange, now string) error { + writes := make([]RolePermissionChange, 0, len(after)) + for _, row := range after { + existing, exists := current[row.ActionID] + if !exists || existing.Allowed != row.Allowed || existing.CapAllowed != row.CapAllowed { + writes = append(writes, row) + } + } + for _, row := range writes { + if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed, + permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`), + roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil { + return err + } + } + return nil +} + +// PatchRolePermissionsBatch applies explicit permission patches to several +// roles in one policy transaction: one authority epoch step, one idempotency +// receipt, and no write at all when any role is stale or invalid. Each +// role's role_version advance is a no-op for stale input. +func (s *Store) PatchRolePermissionsBatch(actor string, items []RolePermissionPatchInput, key string) (RolePermissionBatch, error) { + if len(items) == 0 || len(items) > maxRolePermissionBatch { + return RolePermissionBatch{}, ErrInvalidRolePolicy + } + roleIDs := make([]string, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + if item.RoleID == "" || seen[item.RoleID] { + return RolePermissionBatch{}, ErrInvalidRolePolicy + } + seen[item.RoleID] = true + roleIDs = append(roleIDs, item.RoleID) + } + requestHash := policyRequestHash(struct{ Items []RolePermissionPatchInput }{items}) + var out RolePermissionBatch + err := s.policyWriteBatch(actor, nil, func(tx *sql.Tx, actorAccount Account, _ map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, &out) + if err != nil || replayed { + return err + } + now := nowISO() + batch := RolePermissionBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, + Receipts: make([]RolePermissionBatchReceipt, 0, len(items))} + for _, item := range items { + receipt, err := s.patchRolePermissionsTx(tx, actorAccount.ID, item, now) + if err != nil { + return err + } + receipt.ReceiptID, receipt.AuthorityEpoch = batch.ReceiptID, batch.AuthorityEpoch + batch.Receipts = append(batch.Receipts, receipt) + } + if err := s.checkCapabilityAssociationTx(tx, nil, roleIDs, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = batch + return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, out) + }) + return out, err +} + +// CreateRoleCopyAndAssign copies a role's non-protected permission rows into +// a new enabled role, applies overrides as an explicit patch, and rebinds +// the assigned users, all in one policy transaction. Protected system +// grants, administrator identity, and project memberships are never copied. +func (s *Store) CreateRoleCopyAndAssign(actor string, source RoleCopySource, spec RoleCreate, overrides []RolePermissionChange, assignments []RoleCopyAssignment, key string) (RoleCopyAssignResult, error) { + name, description, err := validateRoleText(spec.Name, spec.Description) + if err != nil || source.RoleID == "" { + return RoleCopyAssignResult{}, ErrInvalidRolePolicy + } + items := make([]UserRoleAssignmentInput, 0, len(assignments)) + for _, assignment := range assignments { + items = append(items, UserRoleAssignmentInput{UserID: assignment.UserID, RoleID: "pending", ExpectedStateVersion: assignment.ExpectedStateVersion}) + } + userIDs, err := assignmentUsers(items) + if err != nil { + return RoleCopyAssignResult{}, err + } + requestHash := policyRequestHash(struct { + Source RoleCopySource + Name string + Description string + Overrides []RolePermissionChange + Assignments []RoleCopyAssignment + }{source, name, description, overrides, assignments}) + var out RoleCopyAssignResult + err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, &out) + if err != nil || replayed { + return err + } + sourceRole, err := s.roleTx(tx, source.RoleID, true) + if errors.Is(err, ErrNotFound) { + return ErrRolePolicyRoleMissing + } + if err != nil { + return err + } + if sourceRole.RoleVersion != source.ExpectedRoleVersion { + return ErrRolePolicyVersion + } + sourceRows, err := s.rolePermissionBitsTx(tx, sourceRole.RoleID) + if err != nil { + return err + } + copied := make(map[string]RolePermissionChange, len(sourceRows)) + for actionID, row := range sourceRows { + if descriptor, ok := catalog.DescriptorByID(actionID); ok && !descriptor.Protected { + copied[actionID] = row + } + } + patch, err := normalizePermissionChanges(overrides) + if err != nil { + return err + } + merged := make(map[string]RolePermissionChange, len(copied)+len(patch)) + for actionID, row := range copied { + merged[actionID] = row + } + for actionID, row := range patch { + merged[actionID] = row + } + sourceWasCreator := projectCreateEnabled(copied) + copyIsCreator := projectCreateEnabled(merged) + if sourceWasCreator && !copyIsCreator { + // Disabling a copy is rejected: the new role would lose the source's + // project.create authority, which is the whole point of copying. + return ErrInvalidRolePolicy + } + now := nowISO() + roleID := newID("role") + if _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + 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 { + return err + } + for i := range items { + items[i].RoleID = roleID + } + bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch) + if err != nil { + return err + } + if err := s.checkCapabilityAssociationTx(tx, userIDs, []string{roleID}, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return err + } + permissions, err := s.rolePermissionRecords(tx, roleID) + if err != nil { + return err + } + out = RoleCopyAssignResult{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, + Role: RoleAdminView{RoleRecord: role, BindingCount: len(bindings), Permissions: permissions}, + Bindings: bindings} + return s.saveReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, out) + }) + return out, err +} + +// ListRoleMembersForActor reads a role's active members, ordered by owner, +// and the account directory revision from one snapshot. The actor's +// delegated authority is rechecked under lock before any member row is read. +func (s *Store) ListRoleMembersForActor(actorOwner, roleID string) ([]RoleMember, AccountDirectoryRevision, error) { + var tx *sql.Tx + var err error + if s.Dialect() == "postgres" { + tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + for _, actionID := range []string{"system.users.view", "system.roles.view"} { + if err := s.requireSystemActionTx(tx, locked[actorOwner], actionID); err != nil { + return nil, AccountDirectoryRevision{}, err + } + } + if _, err := s.roleTx(tx, roleID, false); err != nil { + return nil, AccountDirectoryRevision{}, err + } + rows, err := tx.Query(s.q(`SELECT u.id,u.owner,COALESCE(u.name,''),u.enabled,u.state_version,b.assigned_at,b.binding_version + FROM user_role_bindings b JOIN users u ON u.id=b.user_id WHERE b.role_id=? AND b.active=? ORDER BY u.owner,u.id`), roleID, true) + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + members := []RoleMember{} + for rows.Next() { + var member RoleMember + var assignedAt string + if err := rows.Scan(&member.UserID, &member.Owner, &member.Name, &member.Enabled, &member.StateVersion, &assignedAt, &member.BindingVersion); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + member.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt) + members = append(members, member) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + rows.Close() + var revision AccountDirectoryRevision + if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`). + Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil { + return nil, AccountDirectoryRevision{}, err + } + if err := tx.Commit(); err != nil { + return nil, AccountDirectoryRevision{}, err + } + return members, revision, nil +} + +// ActiveRolesForUsers returns each listed user's active role in one query. +// Users without an active binding are absent from the map. +func (s *Store) ActiveRolesForUsers(userIDs []string) (map[string]UserRoleSummary, error) { + out := make(map[string]UserRoleSummary, len(userIDs)) + if len(userIDs) == 0 { + return out, nil + } + args := []any{true} + for _, userID := range userIDs { + args = append(args, userID) + } + rows, err := s.db.Query(s.q(`SELECT b.user_id,r.role_id,r.name,r.enabled FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id + WHERE b.active=? AND b.user_id IN (`+strings.TrimSuffix(strings.Repeat("?,", len(userIDs)), ",")+`)`), args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var userID string + var role UserRoleSummary + if err := rows.Scan(&userID, &role.RoleID, &role.Name, &role.Enabled); err != nil { + return nil, err + } + out[userID] = role + } + return out, rows.Err() +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index dbf23c9..10e0944 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -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 +}