320 lines
13 KiB
Go
320 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func explainActionMap(actions []CapabilityActionExplain) map[string]CapabilityActionExplain {
|
|
out := map[string]CapabilityActionExplain{}
|
|
for _, action := range actions {
|
|
out[action.ActionID] = action
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
|
|
st := openTestStore(t)
|
|
_, alice, bob := rolePolicyAccounts(t, st)
|
|
role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
|
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "explain-create"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-assign"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
epoch, _ := st.AuthorityEpoch()
|
|
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "explain-grant"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for owner, source := range map[string]string{"alice": "role", "bob": "system-grant", "admin": "bootstrap-admin"} {
|
|
explanation, err := st.ExplainCapabilities(owner)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", owner, err)
|
|
}
|
|
linked, err := st.LinkedCapabilities(owner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capability := explanation.Capability
|
|
if explanation.Owner != owner || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != source ||
|
|
capability.ProjectCreate.Source != linked.Source || capability.ProjectCreate.Scope != CapabilityScopeSystem ||
|
|
len(capability.Conflicts) != 0 || capability.MigrationNote != "" || explanation.AuthorityEpoch != linked.AuthorityEpoch {
|
|
t.Fatalf("%s explanation=%+v linked=%+v", owner, explanation, linked)
|
|
}
|
|
allowed := []string{}
|
|
for _, action := range capability.Linked {
|
|
if !action.Allowed || action.Source != source || action.LinkedVia != "project.create" || action.Scope != CapabilityScopeOwnProjects {
|
|
t.Fatalf("%s linked action=%+v", owner, action)
|
|
}
|
|
allowed = append(allowed, action.ActionID)
|
|
}
|
|
if strings.Join(allowed, ",") != strings.Join(linked.LinkedActions, ",") {
|
|
t.Fatalf("%s explain linked=%v association=%v", owner, allowed, linked.LinkedActions)
|
|
}
|
|
}
|
|
alicePolicy, _ := st.ExplainCapabilities("alice")
|
|
if alicePolicy.RoleID != role.RoleID || alicePolicy.RoleName != "项目负责人" || !alicePolicy.RoleEnabled {
|
|
t.Fatalf("alice subject=%+v", alicePolicy.CapabilitySubject)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing.T) {
|
|
st := openTestStore(t)
|
|
admin, alice, _ := rolePolicyAccounts(t, st)
|
|
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限管理"})
|
|
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)
|
|
}
|
|
if permissions := capabilityRolePermissions(t, st, capped.RoleID); len(permissions) != 0 {
|
|
t.Fatalf("refused cap persisted: %+v", permissions)
|
|
}
|
|
// Bootstrap admin keeps project.create through is_admin even without the
|
|
// grant, so the bound role cannot contradict it either.
|
|
epoch, _ := st.AuthorityEpoch()
|
|
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, []string{"system.permissions.manage", "system.roles.manage", "system.roles.assign"}, "drop-admin-create"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin, _ = st.GetAccount(admin.ID)
|
|
if _, err := st.AssignUserRole("admin", admin.ID, capped.RoleID, admin.StateVersion, "assign-admin-capped"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
explanation, err := st.ExplainCapabilities("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linked, err := st.LinkedCapabilities("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capability := explanation.Capability
|
|
if !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != "bootstrap-admin" ||
|
|
len(capability.Conflicts) != 0 || len(linked.Conflicts) != 0 || capability.MigrationNote != "" {
|
|
t.Fatalf("capability=%+v linked=%+v", capability, linked)
|
|
}
|
|
actions := explainActionMap(capability.Linked)
|
|
if len(actions) != len(linkedManagementActions) || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") {
|
|
t.Fatalf("linked actions=%+v association=%v", capability.Linked, linked.LinkedActions)
|
|
}
|
|
for _, actionID := range linkedManagementActions {
|
|
linkedAction := actions[actionID]
|
|
if !linkedAction.Allowed || linkedAction.Conflict || linkedAction.Source != "bootstrap-admin" ||
|
|
linkedAction.LinkedVia != "project.create" || linkedAction.Scope != CapabilityScopeOwnProjects {
|
|
t.Fatalf("linked %s=%+v", actionID, linkedAction)
|
|
}
|
|
}
|
|
|
|
// Turning the one boolean off stops project.create, and because no linked
|
|
// rows exist there is nothing left for a migration note to report.
|
|
creator, err := st.CreateRole("admin", RoleCreate{Name: "创建者"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
receipt, err := st.ReplaceRolePermissions("admin", creator.RoleID, creator.RoleVersion,
|
|
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-create")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.AssignUserRole("admin", alice.ID, creator.RoleID, alice.StateVersion, "assign-alice-creator"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
granted, err := st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !granted.Capability.ProjectCreate.Allowed || granted.Capability.ProjectCreate.Source != CapabilitySourceRole {
|
|
t.Fatalf("granted create=%+v", granted.Capability.ProjectCreate)
|
|
}
|
|
if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion, nil, "disable-create"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
disabled, err := st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if disabled.Capability.ProjectCreate.Allowed || disabled.Capability.ProjectCreate.Reason != "not-granted" {
|
|
t.Fatalf("disabled create=%+v", disabled.Capability.ProjectCreate)
|
|
}
|
|
if len(disabled.Capability.Linked) != len(linkedManagementActions) {
|
|
t.Fatalf("linked after disable=%+v", disabled.Capability.Linked)
|
|
}
|
|
for _, action := range disabled.Capability.Linked {
|
|
if action.Allowed || action.Reason != "project-create-not-held" {
|
|
t.Fatalf("linked after disable=%+v", action)
|
|
}
|
|
}
|
|
if note := disabled.Capability.MigrationNote; note != "" {
|
|
t.Fatalf("migration note=%q", note)
|
|
}
|
|
if linked, err = st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 {
|
|
t.Fatalf("disabled association=%+v err=%v", linked, err)
|
|
}
|
|
}
|
|
|
|
// TestExplainCapabilityMemberBasicCollaborate is the explanation face of the
|
|
// narrowed role model for member content: the role carries the single
|
|
// project-access boolean, so every member action is reported as unconfigured
|
|
// and a role row for one of them is refused.
|
|
func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
|
|
st := openTestStore(t)
|
|
_, alice, _ := 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}}, "member-policy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
explanation, err := st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
|
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 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 {
|
|
t.Fatal(err)
|
|
}
|
|
explanation, err = st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actions = explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
|
for _, actionID := range memberBasicCollaborateActions {
|
|
want := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeMemberProjects, Reason: "role-disabled"}
|
|
if actions[actionID] != want {
|
|
t.Fatalf("disabled %s=%+v want %+v", actionID, actions[actionID], want)
|
|
}
|
|
}
|
|
unassigned, err := st.ExplainCapabilities("bob")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, action := range unassigned.MemberBasicCollaborate.Actions {
|
|
if action.Allowed || action.Reason != "role-unassigned" {
|
|
t.Fatalf("unassigned member action=%+v", action)
|
|
}
|
|
}
|
|
// Bootstrap admin is a system source only; it grants no member content.
|
|
adminExplain, err := st.ExplainCapabilities("admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, action := range adminExplain.MemberBasicCollaborate.Actions {
|
|
if action.Allowed || action.Source != "" {
|
|
t.Fatalf("admin member action=%+v", action)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExplainCapabilityDocumentQualify is the explanation face of the narrowed
|
|
// role model for document duties: the three duties are not role authority, so
|
|
// every duty is reported unconfigured and a duty row is refused.
|
|
func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
|
st := openTestStore(t)
|
|
_, alice, _ := 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}}, "duty-policy")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
explanation, err := st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string]DocumentDutyQualify{}
|
|
for _, duty := range explanation.DocumentQualify.Duties {
|
|
got[duty.ActionID] = duty
|
|
}
|
|
if len(got) != len(documentQualifyDuties) {
|
|
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
|
|
}
|
|
for _, actionID := range documentQualifyDuties {
|
|
want := DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
|
|
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 {
|
|
t.Fatal(err)
|
|
}
|
|
explanation, err = st.ExplainCapabilities("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, duty := range explanation.DocumentQualify.Duties {
|
|
if duty.Qualified || duty.DefaultAllowed || duty.Source != "" || duty.Reason != "role-disabled" {
|
|
t.Fatalf("disabled role duty=%+v", duty)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExplainCapabilityRejectsMissingAndDisabledAccountsAlike(t *testing.T) {
|
|
st := openTestStore(t)
|
|
rolePolicyAccounts(t, st)
|
|
if err := st.CreateAccount("carol", "Carol", "hash", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
carol, _ := st.GetAccountByOwner("carol")
|
|
if _, err := st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, owner := range []string{"", "nobody", "carol"} {
|
|
explanation, err := st.ExplainCapabilities(owner)
|
|
if !errors.Is(err, ErrAccountForbidden) || explanation.UserID != "" || explanation.Owner != "" {
|
|
t.Fatalf("%q explanation=%+v err=%v", owner, explanation, err)
|
|
}
|
|
}
|
|
}
|