agent-exec transport 64b951970cf4308149cee90d2ab93fbb/078c7847c409
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user