Files
Agent-System-Backend/internal/server/role_authorization_test.go

698 lines
33 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/store"
)
func bearerRequest(h http.Handler, method, path, body, key string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("engineer", "Engineer", "", false); err != nil {
t.Fatal(err)
}
engineer, _ := st.GetAccountByOwner("engineer")
key, err := st.CreateAPIKey("engineer", "engineer-test")
if err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"未授权项目"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("project created without permission: %d %s", rec.Code, rec.Body.String())
}
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)
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"project-create-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if policy.Code != http.StatusOK {
t.Fatalf("set role permission: %d %s", policy.Code, policy.Body.String())
}
assign := do(h, "PUT", "/api/admin/users/"+engineer.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(engineer.StateVersion, 10)+`,"idempotencyKey":"assign-engineer"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign engineer: %d %s", assign.Code, assign.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"授权项目"}`, key); rec.Code != http.StatusOK {
t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String())
}
}
// TestProjectCreateIsTheOnlyRolePolicyOverHTTP pins the narrowed role model
// (R2): a role carries the single project.create boolean, a batch naming any
// other action is refused outright, no linked rows are expanded on write, and
// the /api/me own-project link is a catalog projection of that boolean rather
// than something the role stored.
func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("creator", "Creator", "", false); err != nil {
t.Fatal(err)
}
creator, _ := st.GetAccountByOwner("creator")
key, err := st.CreateAPIKey("creator", "creator-test")
if err != nil {
t.Fatal(err)
}
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)
permissions := "/api/admin/roles/" + role.RoleID + "/permissions"
roleView := func() *httptest.ResponseRecorder {
return do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
}
const enableCreate = `{"actionId":"project.create","allowed":true,"capAllowed":true}`
const denyLinked = `{"actionId":"project.members.manage","allowed":false,"capAllowed":false}`
// A batch that enables project.create and explicitly denies a linked
// own-project action is refused as a whole: the linked action is not role
// authority any more, so nothing in that batch is written.
both := do(h, "PUT", permissions,
`{"expectedRoleVersion":1,"idempotencyKey":"create-and-deny-linked","changes":[`+enableCreate+`,`+denyLinked+`]}`, admin)
if both.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted beside create: %d %s", both.Code, both.Body.String())
}
stale := do(h, "PUT", permissions,
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin)
if stale.Code != http.StatusConflict {
t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String())
}
policy := do(h, "PUT", permissions,
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[`+enableCreate+`]}`, admin)
if policy.Code != http.StatusOK {
t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String())
}
var receipt store.PolicyReceipt
if err := json.Unmarshal(policy.Body.Bytes(), &receipt); err != nil {
t.Fatalf("decode receipt: %v body=%s", err, policy.Body.String())
}
if receipt.ReceiptID == "" || receipt.RoleVersion != 2 || len(receipt.LinkedExpansion) != 0 ||
strings.Contains(policy.Body.String(), "linkedExpansion") {
t.Fatalf("receipt expanded linked rows: %+v body=%s", receipt, policy.Body.String())
}
requireRoleProjectAccess(t, roleView(), 2, true)
// Split across two submissions the linked deny is refused on its own, so the
// role keeps the project.create policy the first submission wrote.
denyLater := do(h, "PUT", permissions,
`{"expectedRoleVersion":2,"idempotencyKey":"deny-linked-later","changes":[`+denyLinked+`]}`, admin)
if denyLater.Code != http.StatusBadRequest {
t.Fatalf("linked deny accepted in a later batch: %d %s", denyLater.Code, denyLater.Body.String())
}
requireRoleProjectAccess(t, roleView(), 2, true)
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
if assign.Code != http.StatusOK {
t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String())
}
me := bearerRequest(h, "GET", "/api/me", "", key)
if me.Code != http.StatusOK {
t.Fatalf("me: %d %s", me.Code, me.Body.String())
}
var identity struct {
Linked struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source"`
LinkedActions []string `json:"linkedActions"`
Conflicts []string `json:"conflicts"`
} `json:"linkedCapabilities"`
}
if err := json.Unmarshal(me.Body.Bytes(), &identity); err != nil {
t.Fatalf("decode me: %v body=%s", err, me.Body.String())
}
if !identity.Linked.ProjectCreate || identity.Linked.Source != "role" || len(identity.Linked.Conflicts) != 0 ||
strings.Join(identity.Linked.LinkedActions, ",") != strings.Join(catalog.ProjectCreateLinkedActions(), ",") {
t.Fatalf("linked capabilities=%+v", identity.Linked)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"创建者项目"}`, key); rec.Code != http.StatusOK {
t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String())
}
}
func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
if err := st.CreateAccount("viewer", "Viewer", "", false); err != nil {
t.Fatal(err)
}
viewer, _ := st.GetAccountByOwner("viewer")
epoch, _ := st.AuthorityEpoch()
grant := do(h, "PUT", "/api/admin/users/"+viewer.ID+"/system-grants",
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"viewer-grant","actions":["system.users.view"]}`, admin)
if grant.Code != http.StatusOK {
t.Fatalf("grant viewer: %d %s", grant.Code, grant.Body.String())
}
key, err := st.CreateAPIKey("viewer", "viewer-test")
if err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "GET", "/api/admin/users", "", key); rec.Code != http.StatusOK {
t.Fatalf("users.view denied: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/admin/users", `{"owner":"forbidden-user"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("users.view created account: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "GET", "/api/admin/roles", "", key); rec.Code != http.StatusForbidden {
t.Fatalf("users.view read roles: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "GET", "/api/me", "", key); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `"systemActions":["system.users.view"]`) {
t.Fatalf("me system actions: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "GET", "/api/authz/explain?action=system.users.view", "", key); rec.Code != http.StatusOK {
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
} else {
var decision struct {
Allowed bool `json:"allowed"`
Source string `json:"source"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil || !decision.Allowed || decision.Source != "system-grant" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
}
}
func TestDelegatedUserActionsReachTransactionalStoreChecks(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
if err := st.CreateAccount("user-manager", "User Manager", "", false); err != nil {
t.Fatal(err)
}
manager, err := st.GetAccountByOwner("user-manager")
if err != nil {
t.Fatal(err)
}
epoch, err := st.AuthorityEpoch()
if err != nil {
t.Fatal(err)
}
grant := do(h, "PUT", "/api/admin/users/"+manager.ID+"/system-grants",
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"grant-user-manager","actions":["system.users.view","system.users.create","system.users.edit","system.users.disable"]}`, admin)
if grant.Code != http.StatusOK {
t.Fatalf("grant: %d %s", grant.Code, grant.Body.String())
}
key, err := st.CreateAPIKey("user-manager", "user-manager-test")
if err != nil {
t.Fatal(err)
}
created := bearerRequest(h, "POST", "/api/admin/users", `{"owner":"managed-user","profile":{"name":"Managed"}}`, key)
if created.Code != http.StatusOK {
t.Fatalf("delegated create: %d %s", created.Code, created.Body.String())
}
var target struct {
UserID string `json:"userId"`
ProfileVersion int64 `json:"profileVersion"`
StateVersion int64 `json:"stateVersion"`
}
if err := json.Unmarshal(created.Body.Bytes(), &target); err != nil {
t.Fatal(err)
}
search := bearerRequest(h, "GET", "/api/admin/users/search?query=Managed&mode=exact&status=enabled&fields=name&limit=20", "", key)
if search.Code != http.StatusOK || !strings.Contains(search.Body.String(), target.UserID) {
t.Fatalf("delegated search: %d %s", search.Code, search.Body.String())
}
patched := bearerRequest(h, "PATCH", "/api/admin/users/"+target.UserID+"/profile",
`{"expectedProfileVersion":`+strconv.FormatInt(target.ProfileVersion, 10)+`,"patch":{"department":"研发"}}`, key)
if patched.Code != http.StatusOK {
t.Fatalf("delegated edit: %d %s", patched.Code, patched.Body.String())
}
disabled := bearerRequest(h, "PUT", "/api/admin/users/"+target.UserID+"/state",
`{"expectedStateVersion":`+strconv.FormatInt(target.StateVersion, 10)+`,"enabled":false}`, key)
if disabled.Code != http.StatusOK {
t.Fatalf("delegated disable: %d %s", disabled.Code, disabled.Body.String())
}
}
// grantSystemActionsFixture creates owner with exactly the given explicit
// system grants (issued by the super administrator) and returns its API key.
func grantSystemActionsFixture(t *testing.T, h http.Handler, st *store.Store, superAdmin, owner string, actions ...string) string {
t.Helper()
if err := st.CreateAccount(owner, owner, "", false); err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner(owner)
if err != nil {
t.Fatal(err)
}
epoch, err := st.AuthorityEpoch()
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]any{"expectedAuthorityEpoch": epoch, "idempotencyKey": "grant-" + owner, "actions": actions})
if rec := do(h, "PUT", "/api/admin/users/"+account.ID+"/system-grants", string(body), superAdmin); rec.Code != http.StatusOK {
t.Fatalf("grant %s: %d %s", owner, rec.Code, rec.Body.String())
}
key, err := st.CreateAPIKey(owner, owner+"-test")
if err != nil {
t.Fatal(err)
}
return key
}
func createAssignedRoleFixture(t *testing.T, h http.Handler, st *store.Store, superAdmin, owner, roleName string, projectAccess bool) string {
t.Helper()
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"`+roleName+`"}`, superAdmin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
if projectAccess {
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"access-`+role.RoleID+`","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, superAdmin)
if policy.Code != http.StatusOK {
t.Fatalf("set project access: %d %s", policy.Code, policy.Body.String())
}
}
account, err := st.GetAccountByOwner(owner)
if err != nil {
t.Fatal(err)
}
assign := do(h, "PUT", "/api/admin/users/"+account.ID+"/role",
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"idempotencyKey":"assign-`+owner+`"}`, superAdmin)
if assign.Code != http.StatusOK {
t.Fatalf("assign %s: %d %s", owner, assign.Code, assign.Body.String())
}
return role.RoleID
}
func systemDecisionFixture(t *testing.T, rec *httptest.ResponseRecorder) (bool, string, string) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
}
var decision struct {
Allowed bool `json:"allowed"`
Source string `json:"source"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil {
t.Fatal(err)
}
return decision.Allowed, decision.Source, decision.Reason
}
func TestProjectAccessIgnoresAdministratorBypass(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
ops := bootOrdinaryAdmin(t, h, st, "ops")
for name, session := range map[string]string{"super": superAdmin, "ordinary": ops} {
if rec := do(h, "POST", "/api/projects", `{"name":"管理员项目-`+name+`"}`, session); rec.Code != http.StatusForbidden {
t.Fatalf("%s admin created project without project access: %d %s", name, rec.Code, rec.Body.String())
}
if allowed, _, reason := systemDecisionFixture(t, do(h, "GET", "/api/authz/explain?action=project.create", "", session)); allowed || reason != "not-granted" {
t.Fatalf("%s admin project.create allowed=%v reason=%s", name, allowed, reason)
}
if allowed, source, _ := systemDecisionFixture(t, do(h, "GET", "/api/authz/explain?action=system.permissions.manage", "", session)); !allowed || source != "bootstrap-admin" {
t.Fatalf("%s admin protected action allowed=%v source=%s", name, allowed, source)
}
me := do(h, "GET", "/api/me", "", session)
if me.Code != http.StatusOK || strings.Contains(me.Body.String(), `"project.create"`) || !strings.Contains(me.Body.String(), `"system.permissions.manage"`) {
t.Fatalf("%s admin system actions: %d %s", name, me.Code, me.Body.String())
}
}
if err := st.CreateAccount("engineer", "Engineer", "", false); err != nil {
t.Fatal(err)
}
engineerKey, err := st.CreateAPIKey("engineer", "engineer-test")
if err != nil {
t.Fatal(err)
}
createAssignedRoleFixture(t, h, st, superAdmin, "engineer", "工程师", true)
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"工程师项目"}`, engineerKey); rec.Code != http.StatusOK {
t.Fatalf("ordinary user with project access: %d %s", rec.Code, rec.Body.String())
}
if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", engineerKey)); !allowed || source != "role" {
t.Fatalf("engineer project.create allowed=%v source=%s", allowed, source)
}
createAssignedRoleFixture(t, h, st, superAdmin, "admin", "管理岗位", true)
if rec := do(h, "POST", "/api/projects", `{"name":"管理员授权项目"}`, superAdmin); rec.Code != http.StatusOK {
t.Fatalf("admin with project-access role: %d %s", rec.Code, rec.Body.String())
}
}
func TestProjectAccessIgnoresSystemGrantWithoutRole(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
key := grantSystemActionsFixture(t, h, st, superAdmin, "granted", "project.create")
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"系统授权项目"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("system grant conferred project access: %d %s", rec.Code, rec.Body.String())
}
}
func TestProjectCreateFailsClosedWithoutRoleProvider(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
if err := st.CreateAccount("engineer", "Engineer", "", false); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey("engineer", "engineer-test")
if err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"无职位项目"}`, key); rec.Code != http.StatusForbidden ||
!strings.Contains(rec.Body.String(), "not-granted") {
t.Fatalf("project created without role: %d %s", rec.Code, rec.Body.String())
}
if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" {
t.Fatalf("missing role project.create allowed=%v reason=%s", allowed, reason)
}
createAssignedRoleFixture(t, h, st, superAdmin, "engineer", "工程师", true)
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"职位授权项目"}`, key); rec.Code != http.StatusOK {
t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String())
}
if _, err := st.DB().Exec(`DROP TABLE role_permissions`); err != nil {
t.Fatal(err)
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"职位不可用项目"}`, key); rec.Code != http.StatusForbidden ||
!strings.Contains(rec.Body.String(), "authority-unavailable") {
t.Fatalf("project created with unavailable role provider: %d %s", rec.Code, rec.Body.String())
}
if allowed, source, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || source != "" || reason != "authority-unavailable" {
t.Fatalf("unavailable role project.create allowed=%v source=%s reason=%s", allowed, source, reason)
}
}
func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
superAdmin := bootSuperAdmin(t, h, st)
if err := st.CreateAccount("engineer", "Engineer", "", false); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey("engineer", "engineer-test")
if err != nil {
t.Fatal(err)
}
roleID := createAssignedRoleFixture(t, h, st, superAdmin, "engineer", "旧模型职位", false)
admin, err := st.GetAccountByOwner("admin")
if err != nil {
t.Fatal(err)
}
legacy := func(actionID string, allowed, capAllowed bool) {
t.Helper()
if _, err := st.DB().Exec(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,1,?,'2026-01-01T00:00:00Z') ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed`,
roleID, actionID, allowed, capAllowed, admin.ID); err != nil {
t.Fatal(err)
}
}
for _, actionID := range []string{"system.settings.read", "system.settings.write", "system.users.view", "system.users.create", "system.roles.view", "document.read"} {
legacy(actionID, true, true)
}
legacy("project.create", false, true)
if rec := bearerRequest(h, "GET", "/api/admin/users", "", key); rec.Code != http.StatusForbidden {
t.Fatalf("legacy role row granted users.view: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "GET", "/api/admin/roles", "", key); rec.Code != http.StatusForbidden {
t.Fatalf("legacy role row granted roles.view: %d %s", rec.Code, rec.Body.String())
}
for _, actionID := range []string{"system.settings.read", "system.users.view", "project.create"} {
if allowed, _, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action="+actionID, "", key)); allowed {
t.Fatalf("legacy role row authorized %s", actionID)
}
}
if rec := bearerRequest(h, "GET", "/api/me", "", key); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"systemActions":[]`) {
t.Fatalf("legacy role system actions: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"仅上限项目"}`, key); rec.Code != http.StatusForbidden {
t.Fatalf("cap-only legacy row granted project access: %d %s", rec.Code, rec.Body.String())
}
roleRec := do(h, "GET", "/api/admin/roles/"+roleID, "", superAdmin)
requireRoleProjectAccess(t, roleRec, 1, false)
list := do(h, "GET", "/api/admin/roles", "", superAdmin)
for _, body := range []string{roleRec.Body.String(), list.Body.String()} {
for _, actionID := range []string{"system.settings", "system.users", "system.roles", "document.read"} {
if strings.Contains(body, actionID) {
t.Fatalf("role API exposed legacy %s: %s", actionID, body)
}
}
}
legacy("project.create", true, true)
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"旧行允许项目"}`, key); rec.Code != http.StatusOK {
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 := bootSuperAdmin(t, h, st)
// Preview is a permission-management read: system.permissions.view is
// required, and holding system.roles.view alone must not unlock it.
roleViewer := grantSystemActionsFixture(t, h, st, admin, "preview-role-viewer", "system.roles.view")
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"预览职位"}`, admin)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
body := `{"roles":[{"roleId":"` + role.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/preview", body, roleViewer); rec.Code != http.StatusForbidden {
t.Fatalf("role viewer previewed permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", body, admin); rec.Code != http.StatusOK {
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
t.Fatalf("preview body: %s", rec.Body.String())
}
// Preview judges every item separately, so an unknown role is reported
// inside that item's preview instead of failing the whole read.
var unknown struct {
Previews []struct {
RoleID string `json:"roleId"`
VersionMatches bool `json:"versionMatches"`
Error string `json:"error"`
ErrorActionID string `json:"errorActionId"`
} `json:"previews"`
}
unknownRec := do(h, "POST", "/api/admin/roles/permissions/preview",
`{"roles":[{"roleId":"unknown","expectedRoleVersion":1,"changes":[]}]}`, admin)
if unknownRec.Code != http.StatusOK {
t.Fatalf("unknown-role preview: %d %s", unknownRec.Code, unknownRec.Body.String())
}
if err := json.Unmarshal(unknownRec.Body.Bytes(), &unknown); err != nil || len(unknown.Previews) != 1 {
t.Fatalf("decode unknown preview: %v body=%s", err, unknownRec.Body.String())
}
// store's roleAssignmentRoleInvalid reason ("角色不存在或已停用"):
// preview reports an unusable role per item instead of failing the read.
const roleInvalidReason = "角色不存在或已停用"
if unknown.Previews[0].RoleID != "unknown" || unknown.Previews[0].VersionMatches ||
unknown.Previews[0].Error != roleInvalidReason || unknown.Previews[0].ErrorActionID != "" {
t.Fatalf("unknown preview item: %+v", unknown.Previews[0])
}
// An empty item list is still a malformed request.
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", `{"roles":[]}`, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("empty preview: %d %s", rec.Code, rec.Body.String())
}
}
func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
// Batch commit is a permission-management write: system.permissions.manage
// is required, roles.view/manage alone is not enough.
roleManager := grantSystemActionsFixture(t, h, st, admin, "batch-role-manager", "system.roles.view", "system.roles.manage")
first := createRoleHTTP(t, h, admin, "批量职位一")
second := createRoleHTTP(t, h, admin, "批量职位二")
body := `{"idempotencyKey":"batch-1","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}` +
`]}`
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/batch", body, roleManager); rec.Code != http.StatusForbidden {
t.Fatalf("role manager patched permissions: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", body, admin); rec.Code != http.StatusOK {
t.Fatalf("batch denied: %d %s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "receipts") {
t.Fatalf("batch body: %s", rec.Body.String())
}
// A stale expectedRoleVersion in any item rejects the whole batch with
// 409 Conflict (main's role-version class) and writes nothing at all.
stale := `{"idempotencyKey":"batch-stale","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", stale, admin); rec.Code != http.StatusConflict {
t.Fatalf("stale batch: %d %s", rec.Code, rec.Body.String())
}
for _, role := range []roleHTTPView{first, second} {
rec := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
view := decodeRoleProjectAccess(t, rec)
if view.RoleVersion != 2 || !view.ProjectAccess || len(view.Permissions) != 1 {
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
}
}
// A change outside project access is an invalid policy: 400 Bad Request.
invalid := `{"idempotencyKey":"batch-invalid","roles":[` +
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"system.users.view","allowed":true,"capAllowed":true}]},` +
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
`]}`
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", invalid, admin); rec.Code != http.StatusBadRequest {
t.Fatalf("invalid batch: %d %s", rec.Code, rec.Body.String())
}
}
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, 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, 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, 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, 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())
}
}
// createRoleHTTP creates a role over HTTP and returns the decoded view. It
// reuses roles_test.go's decodeRoleHTTP so the package keeps one decoder.
func createRoleHTTP(t *testing.T, h http.Handler, admin, name string) roleHTTPView {
t.Helper()
rec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
if rec.Code != http.StatusOK {
t.Fatalf("create role %s: %d %s", name, rec.Code, rec.Body.String())
}
return decodeRoleHTTP(t, rec)
}