From 97c8cc8c239af86a79436ccb58153f5cde01d618 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 11:08:03 +0000 Subject: [PATCH] agent-exec transport f7332a745364f439ad5fdc66fc3e3ec5/3040807e4420 --- internal/store/role_policy_test.go | 155 ++++++++++++++++------------- 1 file changed, 88 insertions(+), 67 deletions(-) diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index eb6f8b3..dbf23c9 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -223,7 +223,11 @@ func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[strin var linkedManagementActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"} -func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) { +// TestRoleProjectCreateLinksFullManagement keeps the projection contract that +// #628 consumers still read: a role whose single project-access boolean is on +// links the own-project management actions, while the role itself stores that +// one boolean and nothing else. +func TestRoleProjectCreateLinksFullManagement(t *testing.T) { st := openTestStore(t) _, alice, _ := rolePolicyAccounts(t, st) role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"}) @@ -235,16 +239,14 @@ func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) if err != nil { t.Fatal(err) } - if strings.Join(receipt.LinkedExpansion, ",") != strings.Join(linkedManagementActions, ",") { + // The narrowed model expands nothing into role rows. + if len(receipt.LinkedExpansion) != 0 { t.Fatalf("expansion=%v", receipt.LinkedExpansion) } permissions := capabilityRolePermissions(t, st, role.RoleID) - for _, actionID := range linkedManagementActions { - permission, ok := permissions[actionID] - // The cap opens the creator's own projects; the default stays off. - if !ok || permission.Allowed || !permission.CapAllowed { - t.Fatalf("%s=%+v ok=%v", actionID, permission, ok) - } + access := permissions["project.create"] + if len(permissions) != 1 || !access.Allowed || !access.CapAllowed { + t.Fatalf("permissions=%+v", permissions) } if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil { t.Fatal(err) @@ -257,12 +259,15 @@ func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) t.Fatalf("linked=%+v", linked) } if replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, - []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 4 { + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 0 { t.Fatalf("replay=%+v err=%v", replayed, err) } } -func TestCapabilityAssociationRejectsExplicitConflictAtomically(t *testing.T) { +// TestRolePolicyRejectsNonProjectAccessRowsAtomically covers the narrowed +// model's fail-closed writer: anything other than the single project.create +// boolean is refused whole, never partially applied. +func TestRolePolicyRejectsNonProjectAccessRowsAtomically(t *testing.T) { st := openTestStore(t) rolePolicyAccounts(t, st) role, err := st.CreateRole("admin", RoleCreate{Name: "受限创建者"}) @@ -282,24 +287,43 @@ func TestCapabilityAssociationRejectsExplicitConflictAtomically(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 cap already closed on the role still blocks a later project.create. - capped := []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}} - receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, capped, "platform-cap") + // A linked action on its own is the same rejection: the role has no row to + // cap off any more, so nothing is written. + 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}}, + } { + 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) + } + if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 { + t.Fatalf("linked-only batch %d persisted=%+v", i, permissions) + } + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create") if err != nil { t.Fatal(err) } - enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} - if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, enable, "enable-over-cap"); !errors.Is(err, ErrInvalidRolePolicy) { - t.Fatalf("existing platform cap conflict: %v", err) + if len(receipt.LinkedExpansion) != 0 { + t.Fatalf("receipt=%+v", receipt) } permissions := capabilityRolePermissions(t, st, role.RoleID) - if _, ok := permissions["project.create"]; ok || len(permissions) != 1 || permissions["project.members.manage"].CapAllowed { - t.Fatalf("permissions after rejected enable=%+v", permissions) + access := permissions["project.create"] + if len(permissions) != 1 || !access.Allowed || !access.CapAllowed { + t.Fatalf("permissions=%+v", permissions) } - resolved := append(enable, RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true}) - if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); err != nil { + // 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) } + if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 1 || !permissions["project.create"].Allowed { + t.Fatalf("rejected resolution changed permissions=%+v", permissions) + } } func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) { @@ -323,9 +347,12 @@ func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) { } } +// TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop keeps the fail-closed +// projection that #628 consumers still read: a direct project.create system +// grant closes the loop on its own, and a role can no longer take it away. func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { st := openTestStore(t) - _, alice, bob := rolePolicyAccounts(t, st) + _, _, bob := rolePolicyAccounts(t, st) if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 { t.Fatalf("ungranted linked=%+v err=%v", linked, err) } @@ -344,30 +371,8 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { t.Fatalf("admin linked=%+v err=%v", admin, err) } - capped, err := st.CreateRole("admin", RoleCreate{Name: "禁止管理"}) - if err != nil { - t.Fatal(err) - } - cappedReceipt, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, - []RolePermissionChange{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: false}}, "capped-role") - if err != nil { - t.Fatal(err) - } - bob, _ = st.GetAccount(bob.ID) - if _, err := st.AssignUserRole("admin", bob.ID, capped.RoleID, bob.StateVersion, "assign-capped-to-creator"); !errors.Is(err, ErrInvalidRolePolicy) { - t.Fatalf("capped role bound to project.create grant holder: %v", err) - } - if _, err := st.AssignUserRole("admin", alice.ID, capped.RoleID, alice.StateVersion, "assign-capped"); err != nil { - t.Fatal(err) - } - epoch, _ = st.AuthorityEpoch() - if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-capped-create"); !errors.Is(err, ErrInvalidRolePolicy) { - t.Fatalf("system grant over role cap: %v", err) - } - if grants, _, err := st.SystemGrantsForUser(alice.ID); err != nil || len(grants) != 0 { - t.Fatalf("rejected grant persisted=%v err=%v", grants, err) - } - // A role writer cannot later close a linked cap under a grant holder either. + // A role carries one project-access boolean, so binding a roleless one + // neither closes nor reopens the direct grant. open, err := st.CreateRole("admin", RoleCreate{Name: "普通职位"}) if err != nil { t.Fatal(err) @@ -376,6 +381,14 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { if _, err := st.AssignUserRole("admin", bob.ID, open.RoleID, bob.StateVersion, "assign-open"); err != nil { t.Fatal(err) } + linked, err = st.LinkedCapabilities("bob") + if err != nil || linked.Source != "system-grant" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") { + t.Fatalf("grant holder with roleless role linked=%+v err=%v", linked, err) + } + 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) @@ -383,49 +396,57 @@ func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 { t.Fatalf("rejected role cap persisted: %+v", permissions) } - if role, _ := st.GetRole(capped.RoleID); role.RoleVersion != cappedReceipt.RoleVersion { - t.Fatalf("capped role changed: %+v", role) + if role, _ := st.GetRole(open.RoleID); role.RoleVersion != open.RoleVersion { + t.Fatalf("rejected write bumped role version: %+v", role) + } + + // Revoking the grant closes the loop again and reports it. + epoch, _ = st.AuthorityEpoch() + revoked, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, nil, "revoke-bob-create") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(revoked.MigrationNote, "project.create disabled") { + t.Fatalf("revoke receipt=%+v", revoked) + } + if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 { + t.Fatalf("revoked linked=%+v err=%v", linked, err) } } -func TestCapabilityAssociationDisablingProjectCreateKeepsLinkedRows(t *testing.T) { +// TestRolePolicyDisablingProjectCreateClearsSingleBoolean covers the narrowed +// model end to end: the role holds one project-access row, turning it off stops +// the projection, and removing it is an explicit second write. +func TestRolePolicyDisablingProjectCreateClearsSingleBoolean(t *testing.T) { st := openTestStore(t) _, alice, bob := rolePolicyAccounts(t, st) role, err := st.CreateRole("admin", RoleCreate{Name: "创建者"}) if err != nil { t.Fatal(err) } - receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ - {ActionID: "project.create", Allowed: true, CapAllowed: true}, - {ActionID: "project.settings", Allowed: true, CapAllowed: true}, - }, "enable-create") + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create") 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 allowed, capAllowed, err := st.RolePermissionAllowed("alice", "project.create"); err != nil || !allowed || !capAllowed { + t.Fatalf("enabled role access=%v cap=%v err=%v", allowed, capAllowed, err) + } + // Disabling is one more project.create row; there are no linked rows left. disabled, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, - []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create") + []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}, "disable-create") if err != nil { t.Fatal(err) } permissions := capabilityRolePermissions(t, st, role.RoleID) - if _, ok := permissions["project.create"]; ok { - t.Fatalf("project.create still present: %+v", permissions) + access := permissions["project.create"] + if len(permissions) != 1 || access.Allowed || access.CapAllowed { + t.Fatalf("permissions after disable=%+v", permissions) } - if settings := permissions["project.settings"]; !settings.Allowed || !settings.CapAllowed { - t.Fatalf("project.settings not kept: %+v", permissions) - } - for _, actionID := range linkedManagementActions { - if _, ok := permissions[actionID]; !ok { - t.Fatalf("%s deleted by disabling project.create: %+v", actionID, permissions) - } - } - if !strings.Contains(disabled.MigrationNote, "project.create disabled") || !strings.Contains(disabled.MigrationNote, "project.settings") { - t.Fatalf("migration note=%q", disabled.MigrationNote) - } - if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate { + if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate || linked.Source != "" { t.Fatalf("disabled create still linked=%+v err=%v", linked, err) } // Removal after project.create is closed is an explicit second write.