Merge latest main into the endpoint integration branch
Why: keep the integration branch current with main's advances. Evidence: host-verified; see Dev PR #706 Impact: integration commit.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -53,16 +54,33 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
|
||||
t.Fatalf("snapshot=%+v", snapshot)
|
||||
}
|
||||
permission := snapshot.Permissions["project.create"]
|
||||
if len(snapshot.Permissions) != 1 || !permission.Allowed || !permission.CapAllowed {
|
||||
if !permission.Allowed || !permission.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", snapshot.Permissions)
|
||||
}
|
||||
// 「项目管理」一个开关即带出项目内工作默认;三权与知识发布不在其中。
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
row, ok := snapshot.Permissions[actionID]
|
||||
if !ok || !row.Allowed || !row.CapAllowed {
|
||||
t.Fatalf("implied %s missing: %+v", actionID, snapshot.Permissions)
|
||||
}
|
||||
}
|
||||
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
|
||||
if row, ok := snapshot.Permissions[duty]; ok && row.Allowed {
|
||||
t.Fatalf("duty %s implied: %+v", duty, snapshot.Permissions)
|
||||
}
|
||||
}
|
||||
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
|
||||
if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID {
|
||||
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||
}
|
||||
legacy, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
|
||||
if err != nil || legacy.Allowed || legacy.Reason != "action-unconfigured" {
|
||||
t.Fatalf("non-project role action=%+v err=%v", legacy, err)
|
||||
// 项目内工作动作随「项目管理」默认放行;三权仍按未配置处理。
|
||||
implied, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
|
||||
if err != nil || !implied.Allowed || !implied.CapAllowed {
|
||||
t.Fatalf("implied project action=%+v err=%v", implied, err)
|
||||
}
|
||||
duty, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.publish")
|
||||
if err != nil || duty.Allowed || duty.Reason != "action-unconfigured" {
|
||||
t.Fatalf("duty action=%+v err=%v", duty, err)
|
||||
}
|
||||
systemProvider, ok := any(h).(extension.SystemActionProvider)
|
||||
if !ok {
|
||||
@@ -128,14 +146,22 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
t.Fatalf("non-project role action accepted: %v", err)
|
||||
// 系统范围动作仍然拒绝;项目范围动作随职位定义。
|
||||
if _, err := admin.SetRolePermission(context.Background(), role.RoleID, "system.settings.write", true, true, "legacy-action"); !errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
t.Fatalf("system role action accepted: %v", err)
|
||||
}
|
||||
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "project.create", true, true, "project-access")
|
||||
access := role.Permissions["project.create"]
|
||||
if err != nil || len(role.Permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
if err != nil || !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("role=%+v err=%v", role, err)
|
||||
}
|
||||
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "project-scope")
|
||||
if err != nil {
|
||||
t.Fatalf("project scope action: %v", err)
|
||||
}
|
||||
if read := role.Permissions["document.read"]; !read.Allowed || !read.CapAllowed {
|
||||
t.Fatalf("project scope permissions=%+v", role.Permissions)
|
||||
}
|
||||
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
|
||||
if err != nil || binding.RoleID != role.RoleID {
|
||||
t.Fatalf("binding=%+v err=%v", binding, err)
|
||||
|
||||
@@ -96,3 +96,31 @@ func IsProjectCreateLinkedAction(id string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// projectManagementImpliedActions 是「项目管理」蕴含的项目内工作默认:拿到项目管理的
|
||||
// 账号在自己参与的项目里可以直接读写文档、管成员、看追溯、推进阶段。三权与知识发布
|
||||
// (document.review/approve/publish/duties.assign、knowledge.publish)不在其中——它们
|
||||
// 仍按项目内的职责分配与三权约束判定。
|
||||
var projectManagementImpliedActions = []string{
|
||||
"document.read",
|
||||
"document.author",
|
||||
"project.stage.advance",
|
||||
"project.archive",
|
||||
"project.audit.read",
|
||||
"trace.edit",
|
||||
}
|
||||
|
||||
// ProjectManagementImpliedActions 返回「项目管理」开启时随角色投影的项目内默认动作。
|
||||
func ProjectManagementImpliedActions() []string {
|
||||
return append([]string(nil), projectManagementImpliedActions...)
|
||||
}
|
||||
|
||||
// IsProjectManagementImpliedAction 报告该动作是否随「项目管理」默认放行。
|
||||
func IsProjectManagementImpliedAction(id string) bool {
|
||||
for _, action := range projectManagementImpliedActions {
|
||||
if action == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -161,17 +161,22 @@ func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, duty, want)
|
||||
}
|
||||
}
|
||||
// Member content is refused at the policy endpoint instead of being stored.
|
||||
// 三权动作是项目范围动作:现在随职位定义,并立刻成为默认值。
|
||||
role := decodeRoleHTTP(t, do(h, "POST", "/api/admin/roles", `{"name":"文档职责"}`, admin))
|
||||
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
accepted := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if accepted.Code != http.StatusOK {
|
||||
t.Fatalf("document duty rejected: %d %s", accepted.Code, accepted.Body.String())
|
||||
}
|
||||
// 系统范围动作仍然整批拒绝,角色保持原样。
|
||||
refused := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"system-policy","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if refused.Code != http.StatusBadRequest {
|
||||
t.Fatalf("document duty accepted: %d %s", refused.Code, refused.Body.String())
|
||||
t.Fatalf("system row accepted: %d %s", refused.Code, refused.Body.String())
|
||||
}
|
||||
// The refused batch left the role untouched: same version, no permissions.
|
||||
stored := decodeRoleProjectAccess(t, do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin))
|
||||
if stored.RoleVersion != 1 || stored.ProjectAccess || len(stored.Permissions) != 0 {
|
||||
t.Fatalf("refused duty row changed the role: %+v", stored)
|
||||
if stored.RoleVersion != 2 || stored.ProjectAccess || len(stored.Permissions) != 1 || stored.Permissions[0].ActionID != "document.author" {
|
||||
t.Fatalf("refused system row changed the role: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -638,9 +638,19 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
|
||||
t.Fatalf("linked cap accepted: %v", err)
|
||||
}
|
||||
permissions, err := f.st.RolePermissions(capped.RoleID)
|
||||
if err != nil || len(permissions) != 1 || !permissions[0].Allowed || !permissions[0].CapAllowed {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byAction := map[string]store.RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
if !byAction["project.create"].Allowed || !byAction["project.create"].CapAllowed {
|
||||
t.Fatalf("refused cap changed the role: %+v err=%v", permissions, err)
|
||||
}
|
||||
if row := byAction["project.members.manage"]; row.Allowed {
|
||||
t.Fatalf("refused cap persisted: %+v", permissions)
|
||||
}
|
||||
if role, err := f.st.GetRole(capped.RoleID); err != nil || role.RoleVersion != receipt.RoleVersion {
|
||||
t.Fatalf("refused cap bumped the role version: %+v err=%v", role, err)
|
||||
}
|
||||
|
||||
@@ -87,13 +87,12 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
|
||||
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.
|
||||
// 系统范围动作仍然整批拒绝;项目范围动作(linked 的管理动作)现在随职位定义。
|
||||
// 开启项目准入时不得在同一职位收回 linked 管理动作(既有不变量)。
|
||||
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())
|
||||
t.Fatalf("linked deny beside create: %d %s", both.Code, both.Body.String())
|
||||
}
|
||||
stale := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[`+enableCreate+`]}`, admin)
|
||||
@@ -115,14 +114,26 @@ func TestProjectCreateIsTheOnlyRolePolicyOverHTTP(t *testing.T) {
|
||||
}
|
||||
requireRoleProjectAccess(t, roleView(), 2, true)
|
||||
|
||||
// 项目范围动作(document.read)可以与项目准入并存;系统范围动作整批拒绝。
|
||||
projectRead := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"create-and-read","changes":[{"actionId":"document.read","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if projectRead.Code != http.StatusOK {
|
||||
t.Fatalf("project read beside create: %d %s", projectRead.Code, projectRead.Body.String())
|
||||
}
|
||||
bothSystem := do(h, "PUT", permissions,
|
||||
`{"expectedRoleVersion":3,"idempotencyKey":"create-and-system","changes":[{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if bothSystem.Code != http.StatusBadRequest {
|
||||
t.Fatalf("system row accepted: %d %s", bothSystem.Code, bothSystem.Body.String())
|
||||
}
|
||||
|
||||
// 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)
|
||||
`{"expectedRoleVersion":3,"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)
|
||||
requireRoleProjectAccess(t, roleView(), 3, true)
|
||||
|
||||
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
|
||||
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
|
||||
@@ -456,13 +467,17 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
|
||||
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"} {
|
||||
for _, actionID := range []string{"system.settings", "system.users", "system.roles"} {
|
||||
if strings.Contains(body, actionID) {
|
||||
t.Fatalf("role API exposed legacy %s: %s", actionID, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(roleRec.Body.String(), "document.read") {
|
||||
t.Fatalf("project scope legacy row hidden: %s", roleRec.Body.String())
|
||||
}
|
||||
|
||||
legacy("project.create", true, true)
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"旧行允许项目"}`, key); rec.Code != http.StatusOK {
|
||||
@@ -562,7 +577,7 @@ func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
|
||||
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 {
|
||||
if view.RoleVersion != 2 || !view.ProjectAccess || !hasRolePermission(view.Permissions, "project.create") {
|
||||
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,14 +89,25 @@ func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type rolePermissionHTTPView struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
}
|
||||
|
||||
type roleProjectAccessHTTPView struct {
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
ProjectAccess bool `json:"projectAccess"`
|
||||
Permissions []struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
} `json:"permissions"`
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
ProjectAccess bool `json:"projectAccess"`
|
||||
Permissions []rolePermissionHTTPView `json:"permissions"`
|
||||
}
|
||||
|
||||
func hasRolePermission(permissions []rolePermissionHTTPView, actionID string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission.ActionID == actionID {
|
||||
return permission.Allowed && permission.CapAllowed
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleProjectAccessHTTPView {
|
||||
@@ -108,20 +119,28 @@ func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleP
|
||||
return view
|
||||
}
|
||||
|
||||
// requireRoleProjectAccess asserts the role endpoint exposes exactly the
|
||||
// normalized project-access boolean and nothing else.
|
||||
// requireRoleProjectAccess asserts the normalized project-access boolean and
|
||||
// that every projected row is a project-scope role default.
|
||||
func requireRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder, version int64, want bool) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read role: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
view := decodeRoleProjectAccess(t, rec)
|
||||
if view.RoleVersion != version || view.ProjectAccess != want || len(view.Permissions) != 1 {
|
||||
if view.RoleVersion != version || view.ProjectAccess != want {
|
||||
t.Fatalf("role access=%+v want version=%d access=%v", view, version, want)
|
||||
}
|
||||
permission := view.Permissions[0]
|
||||
if permission.ActionID != "project.create" || permission.Allowed != want || permission.CapAllowed != want {
|
||||
t.Fatalf("role permission=%+v want=%v", permission, want)
|
||||
var access *rolePermissionHTTPView
|
||||
for i, permission := range view.Permissions {
|
||||
if permission.ActionID == "project.create" {
|
||||
access = &view.Permissions[i]
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("project access row missing=%+v", view.Permissions)
|
||||
}
|
||||
if access.Allowed != want || access.CapAllowed != want {
|
||||
t.Fatalf("role permission=%+v want=%v", access, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,9 @@ func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel is the
|
||||
// explanation face of the narrowed role model (R2): a role stores the single
|
||||
// project-access boolean, a linked action row cannot be written any more, and
|
||||
// an explanation therefore never reports a conflict or a migration note.
|
||||
// explanation face of the role model (R2): a role stores the project-access
|
||||
// boolean plus project-scope defaults (never system-scope rows), and an
|
||||
// explanation therefore never reports a conflict or a migration note.
|
||||
func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
admin, alice, _ := rolePolicyAccounts(t, st)
|
||||
@@ -75,14 +75,13 @@ func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A linked action row is not role authority any more: the write is refused
|
||||
// whole and the role keeps no permissions at all.
|
||||
// 没有项目范围行时职位仍然不携带任何权限;系统范围行整批拒绝。
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked cap accepted: %v", err)
|
||||
[]RolePermissionChange{{ActionID: "system.settings.write", Allowed: true, CapAllowed: true}}, "system-row"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("system row accepted: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, capped.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("refused cap persisted: %+v", permissions)
|
||||
t.Fatalf("refused write persisted: %+v", permissions)
|
||||
}
|
||||
// Bootstrap admin keeps project.create through is_admin even without the
|
||||
// grant, so the bound role cannot contradict it either.
|
||||
@@ -185,30 +184,40 @@ func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Member content is not role authority any more, so the batch is refused
|
||||
// and the role keeps the project-access boolean it already had.
|
||||
// 项目范围动作现在是职位默认的一部分,直接随角色写入。
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
}, "member-content"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("member content accepted: %v", err)
|
||||
}, "member-content"); err != nil {
|
||||
t.Fatalf("member content rejected: %v", err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
||||
// document.read 现在来自角色默认;其余成员动作仍未配置。
|
||||
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
|
||||
t.Fatalf("document.read=%+v", read)
|
||||
}
|
||||
if len(actions) != len(memberBasicCollaborateActions) {
|
||||
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
|
||||
}
|
||||
for _, actionID := range memberBasicCollaborateActions {
|
||||
want := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"}
|
||||
if actionID == "document.read" {
|
||||
want = CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"}
|
||||
}
|
||||
if actions[actionID] != want {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], want)
|
||||
}
|
||||
}
|
||||
// A disabled role stops every member action at the role instead.
|
||||
disabled := false
|
||||
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
roleAfter, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err = st.ExplainCapabilities("alice")
|
||||
@@ -261,12 +270,11 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Assigning a document duty to the role is refused whole; the role keeps the
|
||||
// project-access boolean and nothing else.
|
||||
// 三权动作同样属于职位默认,随角色一起定义。
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.author", Allowed: true, CapAllowed: true},
|
||||
}, "duty-row"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("duty row accepted: %v", err)
|
||||
}, "duty-row"); err != nil {
|
||||
t.Fatalf("duty row rejected: %v", err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
@@ -279,14 +287,22 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
||||
if len(got) != len(documentQualifyDuties) {
|
||||
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
|
||||
}
|
||||
// document.author 已随角色定义,其他三权仍按未配置处理。
|
||||
for _, actionID := range documentQualifyDuties {
|
||||
want := DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
|
||||
if actionID == "document.author" {
|
||||
want = DocumentDutyQualify{ActionID: actionID, Qualified: true, DefaultAllowed: true, Source: "role"}
|
||||
}
|
||||
if got[actionID] != want {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], want)
|
||||
}
|
||||
}
|
||||
disabled := false
|
||||
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
roleAfter, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PatchRole("admin", role.RoleID, roleAfter.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err = st.ExplainCapabilities("alice")
|
||||
|
||||
@@ -405,17 +405,43 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
|
||||
next := map[string]RolePermissionChange{}
|
||||
for _, change := range changes {
|
||||
descriptor, ok := catalog.DescriptorByID(change.ActionID)
|
||||
if !ok || descriptor.Protected || change.ActionID != catalog.ProjectAccessAction ||
|
||||
// 角色可携带项目范围动作:职位默认(负责人上下限)由 Backend 角色快照
|
||||
// 投影到 RDM 的 evalPerm,没有它则项目内读/写一律拒绝。受保护的系统
|
||||
// 授权仍只能按用户单独授予。
|
||||
if !ok || descriptor.Protected ||
|
||||
(change.ActionID != catalog.ProjectAccessAction && descriptor.Scope != catalog.ScopeProject) ||
|
||||
seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
seen[change.ActionID] = true
|
||||
next[change.ActionID] = change
|
||||
}
|
||||
// Rows for other actions are inert legacy audit data and stay untouched.
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
|
||||
// 准入与 linked 管理动作的一致性门:开启 project.create 的职位不得收回
|
||||
// 自己的四个管理动作(有绑定或系统授权的账号同理)。
|
||||
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
|
||||
currentBits, err := s.rolePermissionBitsTx(tx, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, change := range changes {
|
||||
currentBits[change.ActionID] = change
|
||||
}
|
||||
pending.roles[roleID] = currentBits
|
||||
if err := s.checkCapabilityAssociationTx(tx, nil, nil, &pending); err != nil {
|
||||
return err
|
||||
}
|
||||
// 受本次变更影响的动作先清后写;其他动作的历史审计行保持不动。
|
||||
// 空批次沿用旧语义:只清除 project.create(“关闭项目准入”)。
|
||||
if len(changes) == 0 {
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, catalog.ProjectAccessAction); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, change := range changes {
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=? AND action_id=?`), roleID, change.ActionID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := nowISO()
|
||||
for _, change := range changes {
|
||||
access := change.Allowed && change.CapAllowed
|
||||
@@ -558,21 +584,47 @@ func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
|
||||
// RolePermissions returns the role's normalized capability: at most one
|
||||
// project.create record whose allowed and capAllowed both equal the stored
|
||||
// allowed&&capAllowed. Legacy rows for any other action are filtered out.
|
||||
// RolePermissions 投影角色上的全部动作行:项目准入(project.create)与项目范围
|
||||
// 动作(document.*、project.settings 等)都进入快照,否则项目内读写没有任何职位
|
||||
// 默认来源。project.create 继续按 allowed && cap_allowed 归一。
|
||||
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
|
||||
var permission RolePermissionRecord
|
||||
var allowed, capAllowed bool
|
||||
err := s.db.QueryRow(s.q(`SELECT allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? AND action_id=?`),
|
||||
roleID, catalog.ProjectAccessAction).Scan(&allowed, &capAllowed, &permission.PermissionVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return []RolePermissionRecord{}, nil
|
||||
}
|
||||
permissions, err := s.rolePermissionRecords(s.db, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permission.ActionID = catalog.ProjectAccessAction
|
||||
permission.Allowed = allowed && capAllowed
|
||||
permission.CapAllowed = permission.Allowed
|
||||
return []RolePermissionRecord{permission}, nil
|
||||
out := make([]RolePermissionRecord, 0, len(permissions))
|
||||
projectManagement := false
|
||||
for _, permission := range permissions {
|
||||
descriptor, known := catalog.DescriptorByID(permission.ActionID)
|
||||
switch {
|
||||
case permission.ActionID == catalog.ProjectAccessAction:
|
||||
permission.Allowed = permission.Allowed && permission.CapAllowed
|
||||
permission.CapAllowed = permission.Allowed
|
||||
projectManagement = permission.Allowed
|
||||
case known && descriptor.Scope == catalog.ScopeProject:
|
||||
// 项目范围动作按职位默认值投影
|
||||
default:
|
||||
// 系统范围旧行仍是不授权的审计数据,不进快照。
|
||||
continue
|
||||
}
|
||||
out = append(out, permission)
|
||||
}
|
||||
// 角色只承载一个「项目管理」开关:开启即在参与的项目里获得项目内工作默认,
|
||||
// 无需逐项配置;显式写出的项目范围行仍然优先。
|
||||
if projectManagement {
|
||||
seen := map[string]bool{}
|
||||
for _, permission := range out {
|
||||
seen[permission.ActionID] = true
|
||||
}
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
if seen[actionID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, RolePermissionRecord{ActionID: actionID, Allowed: true, CapAllowed: true})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ActionID < out[j].ActionID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
)
|
||||
|
||||
func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) {
|
||||
@@ -208,6 +210,26 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertProjectManagementDefaults 断言「项目管理」开启时投影出的项目内默认动作。
|
||||
func assertProjectManagementDefaults(t *testing.T, permissions map[string]RolePermissionRecord) {
|
||||
t.Helper()
|
||||
for _, actionID := range catalog.ProjectManagementImpliedActions() {
|
||||
row, ok := permissions[actionID]
|
||||
if !ok || !row.Allowed || !row.CapAllowed {
|
||||
t.Fatalf("project management default %s missing: %+v", actionID, permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func projectAccessRecord(permissions []RolePermissionRecord) *RolePermissionRecord {
|
||||
for i, permission := range permissions {
|
||||
if permission.ActionID == "project.create" {
|
||||
return &permissions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord {
|
||||
t.Helper()
|
||||
permissions, err := st.RolePermissions(roleID)
|
||||
@@ -245,9 +267,16 @@ func TestRoleProjectCreateLinksFullManagement(t *testing.T) {
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
access := permissions["project.create"]
|
||||
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
if !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", permissions)
|
||||
}
|
||||
// 项目管理蕴含项目内工作默认;三权与知识发布不在其中。
|
||||
assertProjectManagementDefaults(t, permissions)
|
||||
for _, duty := range []string{"document.review", "document.approve", "document.publish", "document.duties.assign", "knowledge.publish"} {
|
||||
if row, ok := permissions[duty]; ok && row.Allowed {
|
||||
t.Fatalf("duty action %s implied by project management: %+v", duty, permissions)
|
||||
}
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -277,7 +306,7 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
conflict := []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.settings", Allowed: false, CapAllowed: false},
|
||||
{ActionID: "system.settings.write", Allowed: false, CapAllowed: false},
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("explicit role conflict: %v", err)
|
||||
@@ -287,20 +316,22 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch {
|
||||
t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch)
|
||||
}
|
||||
// A linked action on its own is the same rejection: the role has no row to
|
||||
// cap off any more, so nothing is written.
|
||||
// 项目范围动作可以单独写入并即刻生效(项目内读写的职位默认值来源)。
|
||||
version := role.RoleVersion
|
||||
for i, only := range [][]RolePermissionChange{
|
||||
{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}},
|
||||
{{ActionID: "project.settings", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: true}},
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "document.author", Allowed: false, CapAllowed: true}},
|
||||
} {
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, only, "linked-only-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked-only batch %d accepted: %v", i, err)
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, version, only, "project-scope-"+string(rune('a'+i)))
|
||||
if err != nil {
|
||||
t.Fatalf("project-scope batch %d rejected: %v", i, err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("linked-only batch %d persisted=%+v", i, permissions)
|
||||
version = receipt.RoleVersion
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) == 0 {
|
||||
t.Fatalf("project-scope batch %d persisted nothing", i)
|
||||
}
|
||||
}
|
||||
role.RoleVersion = version
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create")
|
||||
if err != nil {
|
||||
@@ -311,18 +342,20 @@ func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) {
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
access := permissions["project.create"]
|
||||
if len(permissions) != 1 || !access.Allowed || !access.CapAllowed {
|
||||
if !access.Allowed || !access.CapAllowed {
|
||||
t.Fatalf("permissions=%+v", permissions)
|
||||
}
|
||||
// A later batch cannot resolve a conflict either, because no second row is
|
||||
// accepted beside the project-access boolean.
|
||||
resolved := append([]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||
RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true})
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("explicit resolution: %v", err)
|
||||
// 项目范围动作可以与项目准入并存在同一批次。
|
||||
current, err := st.GetRole(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 1 || !permissions["project.create"].Allowed {
|
||||
t.Fatalf("rejected resolution changed permissions=%+v", permissions)
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, current.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.publish", Allowed: true, CapAllowed: true}}, "project-publish"); err != nil {
|
||||
t.Fatalf("project publish row: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); !permissions["document.publish"].Allowed {
|
||||
t.Fatalf("project publish row missing=%+v", permissions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,16 +421,13 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) {
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("roleless role kept permissions=%+v", permissions)
|
||||
}
|
||||
// A linked row cannot be written beside that grant holder either.
|
||||
// 项目范围动作与系统授权并存:系统授权仍是唯一项目准入来源。
|
||||
if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("role cap under grant holder: %v", err)
|
||||
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "read-under-holder"); err != nil {
|
||||
t.Fatalf("project read under grant holder: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("rejected role cap persisted: %+v", permissions)
|
||||
}
|
||||
if role, _ := st.GetRole(open.RoleID); role.RoleVersion != open.RoleVersion {
|
||||
t.Fatalf("rejected write bumped role version: %+v", role)
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); !permissions["document.read"].Allowed {
|
||||
t.Fatalf("project read row under grant holder=%+v", permissions)
|
||||
}
|
||||
|
||||
// Revoking the grant closes the loop again and reports it.
|
||||
@@ -605,20 +635,40 @@ func TestRolePermissionsNormalizeLegacyRowsToProjectAccess(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
insertLegacyRolePermission(t, st, role.RoleID, "project.create", tc.allowed, tc.capAllowed)
|
||||
// 项目范围旧行(document.read)随职位投影;系统范围旧行仍被丢弃;
|
||||
// 项目准入开启时另带出项目管理蕴含的项目内默认。
|
||||
permissions, err := st.RolePermissions(role.RoleID)
|
||||
if err != nil || len(permissions) != 1 {
|
||||
t.Fatalf("permissions=%+v err=%v", permissions, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := permissions[0]
|
||||
if got.ActionID != "project.create" || got.Allowed != tc.want || got.CapAllowed != tc.want {
|
||||
byAction := map[string]RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
got := byAction["project.create"]
|
||||
if got.Allowed != tc.want || got.CapAllowed != tc.want {
|
||||
t.Fatalf("normalized=%+v want=%v", got, tc.want)
|
||||
}
|
||||
if read := byAction["document.read"]; !read.Allowed || !read.CapAllowed {
|
||||
t.Fatalf("project scope row dropped=%+v", permissions)
|
||||
}
|
||||
if tc.want {
|
||||
assertProjectManagementDefaults(t, byAction)
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if strings.HasPrefix(permission.ActionID, "system.") {
|
||||
t.Fatalf("system row projected=%+v", permissions)
|
||||
}
|
||||
}
|
||||
view, err := st.RoleAdmin(role.RoleID)
|
||||
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) != 1 || view.Permissions[0].ActionID != "project.create" {
|
||||
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) < 2 {
|
||||
t.Fatalf("role admin=%+v err=%v", view, err)
|
||||
}
|
||||
if access := projectAccessRecord(view.Permissions); access == nil || access.Allowed != tc.want {
|
||||
t.Fatalf("role admin access row=%+v want=%v", access, tc.want)
|
||||
}
|
||||
roles, _, err := st.ListRoles("", "fuzzy", "all", "", "", 20)
|
||||
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) != 1 {
|
||||
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) < 2 {
|
||||
t.Fatalf("list roles=%+v err=%v", roles, err)
|
||||
}
|
||||
allowed, capAllowed, err := st.RolePermissionAllowed("alice", "project.create")
|
||||
@@ -711,12 +761,14 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
|
||||
}
|
||||
return allowed, capAllowed, rows
|
||||
}
|
||||
// 项目范围动作(如 document.read)现在可以随职位一起定义;受保护的系统授权
|
||||
// 仍然拒绝,并且任何失败批次都不部分写入。
|
||||
for i, bad := range [][]RolePermissionChange{
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.settings.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "system.users.create", Allowed: false, CapAllowed: false}},
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: true}, {ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||
{{ActionID: "project.create", Allowed: true, CapAllowed: false}},
|
||||
{{ActionID: "document.read", Allowed: true, CapAllowed: false}},
|
||||
} {
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "bad-"+string(rune('a'+i))); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("batch %d accepted: %v", i, err)
|
||||
@@ -1252,3 +1304,73 @@ func getRoleByName(st *Store, name string) (RoleRecord, bool) {
|
||||
}
|
||||
return roles[0].RoleRecord, true
|
||||
}
|
||||
|
||||
// TestRoleCarriesProjectScopeDefaults covers the project-scope role rows: a role
|
||||
// may define the project defaults that RDM projects them onto project work, and
|
||||
// a row that caps a linked management action is reported as a conflict instead of
|
||||
// being silently ignored.
|
||||
func TestRoleCarriesProjectScopeDefaults(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "项目工作职位"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, _ = st.GetAccount(alice.ID)
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-work"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.author", Allowed: false, CapAllowed: true},
|
||||
}, "project-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions, err := st.RolePermissions(role.RoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byAction := map[string]RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
byAction[permission.ActionID] = permission
|
||||
}
|
||||
if !byAction["project.create"].Allowed || !byAction["document.read"].Allowed {
|
||||
t.Fatalf("project defaults=%+v", byAction)
|
||||
}
|
||||
if byAction["document.author"].Allowed {
|
||||
t.Fatalf("explicit revoke=%+v", byAction["document.author"])
|
||||
}
|
||||
alice, _ = st.GetAccount(alice.ID)
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
||||
if read := actions["document.read"]; !read.Allowed || read.Source != "role" {
|
||||
t.Fatalf("member read=%+v", read)
|
||||
}
|
||||
if author := actions["document.author"]; author.Allowed {
|
||||
t.Fatalf("member author=%+v", author)
|
||||
}
|
||||
// 项目准入开启时不得在同一职位里收回 linked 管理动作(既有不变量)。
|
||||
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限工作职位"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.members.manage", Allowed: false, CapAllowed: false},
|
||||
}, "cap-linked"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("linked deny beside create accepted: %v", err)
|
||||
}
|
||||
// 同一动作的显式授予合法,并立即成为快照里的默认值。
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.members.manage", Allowed: true, CapAllowed: true},
|
||||
}, "allow-linked"); err != nil {
|
||||
t.Fatalf("linked allow beside create: %v", err)
|
||||
}
|
||||
_ = receipt
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user