Merge pull request 'Land the project access and lifecycle role and work-state work on Backend main' (#234) from feature/project-access-lifecycle-u3-backend-merge into main
governance / governance (push) Successful in 14s
ci / vet-and-test (push) Successful in 1m15s

This commit was merged in pull request #234.
This commit is contained in:
2026-10-21 04:06:34 +00:00
17 changed files with 1282 additions and 315 deletions
+5 -2
View File
@@ -36,13 +36,16 @@ profiles:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
# 2026-10-20 临时禁用:cursor 账本月配额耗尽(重置 2026-11-10),原因记录于
# Dev U3 任务卡;恢复时改回 enabled: true。
enabled: false
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
# 2026-10-20 临时禁用:Gemini 端点连续 3 次在最终流式响应 EOF(长会话不稳定),恢复时改回 enabled: true。
enabled: false
pi:
adapter: pi
+19 -7
View File
@@ -26,11 +26,15 @@ func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []store.RolePermissionChange{
{ActionID: "project.create", Allowed: true, CapAllowed: true},
{ActionID: "document.read", Allowed: true, CapAllowed: true},
{ActionID: "document.review", Allowed: false, CapAllowed: true},
}, "explain-provider"); err != nil {
// 收窄模型(R2):职位角色只携带单一 project.create 行;document.* /
// project.* 等其他动作由系统授权或项目成员关系承担,写到角色行被
// ReplaceRolePermissions 拒绝(ErrInvalidRolePolicy)。fixture 只设
// project.create,断言围绕这条窄权限:CreateAuthority 投影到四个
// ProjectCreateLinkedActions、MemberBasicCollaborate 全 action-unconfigured、
// DocumentQualify 三职责都 action-unconfigured。
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
"explain-provider"); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil {
@@ -59,8 +63,11 @@ func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 {
t.Fatalf("member=%+v err=%v", member, err)
}
// 角色权限投影:document.read 不在职位权限(只有 project.create),因此不能
// 被 Source=CapabilitySourceRole 标记为允许;来源必须是空、Reason 必须是
// "action-unconfigured"——这条边界保护正是窄模型的保护意图。
for _, action := range member.Actions {
if action.ActionID == "document.read" && (!action.Allowed || action.Source != extension.CapabilitySourceRole) {
if action.ActionID == "document.read" && (action.Allowed || action.Source == extension.CapabilitySourceRole || action.Reason != "action-unconfigured") {
t.Fatalf("document.read=%+v", action)
}
}
@@ -68,8 +75,13 @@ func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 {
t.Fatalf("document=%+v err=%v", document, err)
}
// 三职责不被职位权限(project.create)直接资格化,因此 Qualified=false、
// DefaultAllowed=false、Source 空、Reason 为 action-unconfigured;这与
// 历史上「Allowed=false CapAllowed=true 单独写入」的职责资格语义不同——
// 那条路径已被 ReplaceRolePermissions 拒绝,本测试在不依赖该历史路径的
// 前提下保留职责资格投影的边界验证。
for _, duty := range document.Duties {
if duty.ActionID == "document.review" && (!duty.Qualified || duty.DefaultAllowed) {
if duty.ActionID == "document.review" && (duty.Qualified || duty.DefaultAllowed || duty.Source == extension.CapabilitySourceRole || duty.Reason != "action-unconfigured") {
t.Fatalf("document.review=%+v", duty)
}
}
+23 -8
View File
@@ -27,7 +27,7 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "provider-policy")
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "provider-policy")
if err != nil {
t.Fatal(err)
}
@@ -52,14 +52,18 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
!snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch {
t.Fatalf("snapshot=%+v", snapshot)
}
permission := snapshot.Permissions["document.read"]
if !permission.Allowed || !permission.CapAllowed {
t.Fatalf("permission=%+v", permission)
permission := snapshot.Permissions["project.create"]
if len(snapshot.Permissions) != 1 || !permission.Allowed || !permission.CapAllowed {
t.Fatalf("permissions=%+v", snapshot.Permissions)
}
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
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)
}
systemProvider, ok := any(h).(extension.SystemActionProvider)
if !ok {
t.Fatal("dispatch host missing SystemActionProvider")
@@ -69,7 +73,7 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
t.Fatalf("system decision=%+v err=%v", systemDecision, err)
}
updated, err := st.ReplaceRolePermissions("admin", role.RoleID, snapshot.RoleVersion,
[]store.RolePermissionChange{{ActionID: "document.read", Allowed: false, CapAllowed: true}}, "provider-policy-update")
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}, "provider-policy-update")
if err != nil {
t.Fatal(err)
}
@@ -77,6 +81,13 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
if err != nil || refreshed.AuthorityEpoch != updated.AuthorityEpoch || refreshed.RoleVersion != updated.RoleVersion {
t.Fatalf("refreshed=%+v receipt=%+v err=%v", refreshed, updated, err)
}
if denied := refreshed.Permissions["project.create"]; len(refreshed.Permissions) != 1 || denied.Allowed || denied.CapAllowed {
t.Fatalf("refreshed permissions=%+v", refreshed.Permissions)
}
revoked, err := provider.CheckRoleAction(context.Background(), alice.ID, "project.create")
if err != nil || revoked.Allowed || revoked.CapAllowed {
t.Fatalf("revoked decision=%+v err=%v", revoked, err)
}
}
func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) {
@@ -117,8 +128,12 @@ func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T)
if err != nil {
t.Fatal(err)
}
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action")
if err != nil || !role.Permissions["document.read"].Allowed {
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)
}
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 {
t.Fatalf("role=%+v err=%v", role, err)
}
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
+5
View File
@@ -9,6 +9,11 @@ const (
ScopeProject Scope = "project"
)
// ProjectAccessAction is the only action a position role carries (R2). Every
// other catalog action comes from system grants or project membership; role
// rows naming them are retained for audit but never authorize anything.
const ProjectAccessAction = "project.create"
type Descriptor struct {
ActionID string `json:"actionId"`
Label string `json:"label"`
+7
View File
@@ -16,6 +16,13 @@ func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) {
}
}
func TestCatalogProjectAccessIsTheOnlyRoleAction(t *testing.T) {
access, ok := DescriptorByID(ProjectAccessAction)
if !ok || access.ActionID != "project.create" || access.Scope != ScopeSystem || access.Protected {
t.Fatalf("project access descriptor=%+v ok=%v", access, ok)
}
}
func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) {
linked := ProjectCreateLinkedActions()
if len(linked) != 4 {
+15 -11
View File
@@ -47,6 +47,21 @@ func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
decision.Reason = "account-unavailable"
return decision
}
// Project access comes only from the actor's role (R2): neither is_admin
// nor a system grant confers it.
if actionID == catalog.ProjectAccessAction {
roleAllowed, _, err := s.store.RolePermissionAllowed(actor, actionID)
if err != nil {
decision.Reason = "authority-unavailable"
return decision
}
if roleAllowed {
decision.Allowed, decision.Source = true, "role"
return decision
}
decision.Reason = "not-granted"
return decision
}
// is_admin remains the narrow bootstrap/root recovery source. Ordinary
// delegated management uses explicit system_grants.
if account.IsAdmin {
@@ -62,17 +77,6 @@ func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
decision.Allowed, decision.Source = true, "system-grant"
return decision
}
if actionID == "project.create" {
roleAllowed, capAllowed, err := s.store.RolePermissionAllowed(actor, actionID)
if err != nil {
decision.Reason = "authority-unavailable"
return decision
}
if roleAllowed && capAllowed {
decision.Allowed, decision.Source = true, "role"
return decision
}
}
decision.Reason = "not-granted"
return decision
}
+38 -20
View File
@@ -116,44 +116,62 @@ func TestCapabilityExplainHTTPReturnsCreatorSourceAndLinkedActions(t *testing.T)
}
}
// TestMemberBasicCollaborateAndDocumentQualifyOverHTTP pins the narrowed role
// model (R2) on the explanation surface: a role carries the single
// project-access boolean, so it neither grants project.create to a member nor
// configures any member action or document duty.
func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
_, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[
{"actionId":"document.read","allowed":true,"capAllowed":true},
{"actionId":"trace.edit","allowed":false,"capAllowed":true},
{"actionId":"document.author","allowed":true,"capAllowed":true},
{"actionId":"document.approve","allowed":false,"capAllowed":false}]`)
_, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[{"actionId":"project.create","allowed":false,"capAllowed":true}]`)
body := decodeCapabilityExplain(t, bearerRequest(h, "GET", "/api/capability/explain", "", key))
if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "not-granted" {
t.Fatalf("member project.create=%+v", body.Capability.ProjectCreate)
if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "role-cap" ||
len(body.Capability.Linked) != 4 || len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" {
t.Fatalf("member capability=%+v", body.Capability)
}
for _, action := range body.Capability.Linked {
if action.Allowed || action.Source != "" || action.Reason != "project-create-not-held" || action.Scope != "own-projects" {
t.Fatalf("member linked=%+v", action)
}
}
member := map[string]extension.CapabilityActionExplain{}
for _, action := range body.MemberBasicCollaborate.Actions {
member[action.ActionID] = action
}
if read := member["document.read"]; !read.Allowed || read.Source != "role" || read.Scope != "member-projects" {
t.Fatalf("document.read=%+v", read)
if len(member) != 4 {
t.Fatalf("member actions=%+v", body.MemberBasicCollaborate.Actions)
}
if trace := member["trace.edit"]; trace.Allowed || !trace.CapAllowed || trace.Reason != "role-default-deny" {
t.Fatalf("trace.edit=%+v", trace)
}
if audit := member["project.audit.read"]; audit.Allowed || audit.Reason != "action-unconfigured" {
t.Fatalf("project.audit.read=%+v", audit)
for actionID, action := range member {
want := extension.CapabilityActionExplain{ActionID: actionID, Scope: "member-projects", Reason: "action-unconfigured"}
if action != want {
t.Fatalf("%s=%+v want %+v", actionID, action, want)
}
}
duties := map[string]extension.DocumentDutyQualify{}
for _, duty := range body.DocumentQualify.Duties {
duties[duty.ActionID] = duty
}
if author := duties["document.author"]; !author.Qualified || !author.DefaultAllowed || author.Source != "role" {
t.Fatalf("document.author=%+v", author)
if len(duties) != 3 {
t.Fatalf("duties=%+v", body.DocumentQualify.Duties)
}
if review := duties["document.review"]; review.Qualified || review.Reason != "action-unconfigured" {
t.Fatalf("document.review=%+v", review)
for actionID, duty := range duties {
want := extension.DocumentDutyQualify{ActionID: actionID, Reason: "action-unconfigured"}
if duty != want {
t.Fatalf("%s=%+v want %+v", actionID, duty, want)
}
}
if approve := duties["document.approve"]; approve.Qualified || approve.Reason != "role-cap" {
t.Fatalf("document.approve=%+v", approve)
// 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",
`{"expectedRoleVersion":1,"idempotencyKey":"duty-policy","changes":[{"actionId":"document.author","allowed":true,"capAllowed":true}]}`, admin)
if refused.Code != http.StatusBadRequest {
t.Fatalf("document duty 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)
}
}
+80 -40
View File
@@ -23,6 +23,10 @@ import (
const (
iamMemberAddCommand = "rdm.member.add"
iamCreatorPolicy = `[{"actionId":"project.create","allowed":true,"capAllowed":true}]`
// iamMemberPolicy is the narrowed model's member role: the single
// project-access boolean, explicitly off. Nothing else is role authority,
// so a member role cannot name a document action any more.
iamMemberPolicy = `[{"actionId":"project.create","allowed":false,"capAllowed":true}]`
)
var iamLinkedActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"}
@@ -375,7 +379,7 @@ func TestProjectIAMCapabilityAssociationChain(t *testing.T) {
func TestProjectIAMMemberAddViaProjectLifecycle(t *testing.T) {
f := newIAMFixture(t)
_, alice := explainRoleFixture(t, f.h, f.st, f.admin, "alice", "项目创建者", iamCreatorPolicy)
_, bob := explainRoleFixture(t, f.h, f.st, f.admin, "bob", "协作成员", `[{"actionId":"document.read","allowed":true,"capAllowed":true}]`)
_, bob := explainRoleFixture(t, f.h, f.st, f.admin, "bob", "协作成员", iamMemberPolicy)
carol, _ := createLegacyAPIKeyFixture(t, f.st, "carol", "test")
project := f.createProject(t, alice, "成员项目")
conversations := "/api/projects/" + project.ID + "/conversations"
@@ -454,25 +458,26 @@ func TestProjectIAMSubjectScopingOnMemberAdd(t *testing.T) {
func TestProjectIAMDocumentReadDecoupledFromMembership(t *testing.T) {
f := newIAMFixture(t)
_, alice := explainRoleFixture(t, f.h, f.st, f.admin, "alice", "项目创建者", iamCreatorPolicy)
_, dave := explainRoleFixture(t, f.h, f.st, f.admin, "dave", "知识编制者", `[
{"actionId":"document.author","allowed":true,"capAllowed":true},
{"actionId":"knowledge.publish","allowed":true,"capAllowed":true}]`)
_, dave := explainRoleFixture(t, f.h, f.st, f.admin, "dave", "知识编制者", iamMemberPolicy)
bob, _ := createLegacyAPIKeyFixture(t, f.st, "bob", "test")
carol, _ := createLegacyAPIKeyFixture(t, f.st, "carol", "test")
project := f.createProject(t, alice, "文档项目")
f.mustAddMember(t, alice, project.ID, "bob")
f.mustAddMember(t, alice, project.ID, "dave")
// A member role grants no document duty under the narrowed model, so the
// document read decision below comes from the resource authorizer alone.
qualify, err := f.explainHost("dave").CurrentDocumentQualify(context.Background())
if err != nil {
t.Fatal(err)
}
authorQualified := false
for _, duty := range qualify.Duties {
authorQualified = authorQualified || (duty.ActionID == "document.author" && duty.Qualified && duty.DefaultAllowed)
if len(qualify.Duties) != 3 {
t.Fatalf("duties=%+v", qualify.Duties)
}
if !authorQualified {
t.Fatalf("dave is not a qualified author: %+v", qualify.Duties)
for _, duty := range qualify.Duties {
if duty.Qualified || duty.DefaultAllowed || duty.Source != "" || duty.Reason != "action-unconfigured" {
t.Fatalf("member role granted a document duty: %+v", duty)
}
}
artifact := core.Artifact{ProjectID: project.ID, Owner: "alice", Name: "设计说明.md", Kind: "document", MIME: "text/markdown"}
@@ -520,9 +525,7 @@ func TestProjectIAMDocumentReadDecoupledFromMembership(t *testing.T) {
func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
f := newIAMFixture(t)
creator, creatorKey := explainRoleFixture(t, f.h, f.st, f.admin, "creator", "项目创建者", iamCreatorPolicy)
member, memberKey := explainRoleFixture(t, f.h, f.st, f.admin, "member", "协作成员", `[
{"actionId":"document.read","allowed":true,"capAllowed":true},
{"actionId":"trace.edit","allowed":false,"capAllowed":true}]`)
member, memberKey := explainRoleFixture(t, f.h, f.st, f.admin, "member", "协作成员", iamMemberPolicy)
ctx := context.Background()
compareHTTP := func(label, key string, got extension.CapabilityExplain) {
@@ -561,8 +564,11 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// The member role carries the single project-access boolean, explicitly
// off, so project.create is not granted and every linked action is refused.
if memberExplain.Owner != "member" || memberExplain.UserID != member.ID || memberExplain.ProjectCreate.Allowed ||
memberExplain.ProjectCreate.Reason != "not-granted" || memberExplain.Conflicts == nil || len(memberExplain.Conflicts) != 0 {
memberExplain.ProjectCreate.Reason != "role-cap" || memberExplain.Conflicts == nil || len(memberExplain.Conflicts) != 0 ||
memberExplain.MigrationNote != "" {
t.Fatalf("member explain=%+v", memberExplain)
}
for _, action := range memberExplain.Linked {
@@ -579,16 +585,20 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
for _, action := range collaborate.Actions {
actions[action.ActionID] = action
}
if read := actions["document.read"]; collaborate.Owner != "member" || !read.Allowed || read.Source != extension.CapabilitySourceRole ||
read.Scope != extension.CapabilityScopeMemberProjects {
t.Fatalf("member document.read=%+v", read)
if collaborate.Owner != "member" || len(actions) != 4 {
t.Fatalf("member collaborate=%+v", collaborate.Actions)
}
if trace := actions["trace.edit"]; trace.Allowed || trace.Reason != "role-default-deny" {
t.Fatalf("member trace.edit=%+v", trace)
// Member content is no longer role authority, so a bound role configures
// none of it.
for actionID, action := range actions {
want := extension.CapabilityActionExplain{ActionID: actionID, Scope: extension.CapabilityScopeMemberProjects, Reason: "action-unconfigured"}
if action != want {
t.Fatalf("member %s=%+v want %+v", actionID, action, want)
}
}
// The bootstrap administrator keeps project.create while an enabled role caps
// one linked action, which is the only state that reports a conflict.
// The bootstrap administrator keeps project.create, and a role can no
// longer cap a linked action, so no explanation reports a conflict.
adminAccount, err := f.st.GetAccountByOwner("admin")
if err != nil {
t.Fatal(err)
@@ -613,10 +623,24 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := f.st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil {
receipt, err := f.st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "enable-capped")
if err != nil {
t.Fatal(err)
}
// A linked action row is not role authority any more, so the cap the retired
// model allowed is refused outright and the role keeps project access.
if _, err := f.st.ReplaceRolePermissions("admin", capped.RoleID, receipt.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); !errors.Is(err, store.ErrInvalidRolePolicy) {
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 {
t.Fatalf("refused cap changed the role: %+v err=%v", permissions, err)
}
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)
}
if _, err := f.st.AssignUserRole("admin", adminAccount.ID, capped.RoleID, adminAccount.StateVersion, "assign-admin-capped"); err != nil {
t.Fatal(err)
}
@@ -624,33 +648,39 @@ func TestProjectIAMCapabilityExplainCreatorAndMember(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if adminExplain.ProjectCreate.Source != extension.CapabilitySourceBootstrapAdmin || !reflect.DeepEqual(adminExplain.Conflicts, []string{"project.members.manage"}) ||
!strings.Contains(adminExplain.MigrationNote, "project.members.manage") {
t.Fatalf("conflicted creator explain=%+v", adminExplain)
if !adminExplain.ProjectCreate.Allowed || adminExplain.ProjectCreate.Source != extension.CapabilitySourceBootstrapAdmin ||
len(adminExplain.Conflicts) != 0 || adminExplain.MigrationNote != "" {
t.Fatalf("bootstrap creator explain=%+v", adminExplain)
}
linkedAdmin := []string{}
for _, action := range adminExplain.Linked {
conflicted := action.ActionID == "project.members.manage"
if conflicted && (action.Allowed || !action.Conflict || action.Reason != "role-cap" || action.Source != "") ||
!conflicted && (!action.Allowed || action.Source != extension.CapabilitySourceBootstrapAdmin || action.Conflict) {
t.Fatalf("conflicted creator linked=%+v", action)
if !action.Allowed || action.Conflict || action.Source != extension.CapabilitySourceBootstrapAdmin ||
action.LinkedVia != "project.create" || action.Scope != extension.CapabilityScopeOwnProjects {
t.Fatalf("bootstrap creator linked=%+v", action)
}
linkedAdmin = append(linkedAdmin, action.ActionID)
}
if !reflect.DeepEqual(linkedAdmin, iamLinkedActions) {
t.Fatalf("bootstrap creator linked actions=%v", linkedAdmin)
}
adminHTTP := decodeCapabilityExplain(t, do(f.h, http.MethodGet, "/api/capability/explain", "", f.admin))
if !reflect.DeepEqual(adminHTTP.Capability.Conflicts, adminExplain.Conflicts) || !reflect.DeepEqual(adminHTTP.Capability.Linked, adminExplain.Linked) {
t.Fatalf("conflicted host/HTTP differ: %+v vs %+v", adminExplain, adminHTTP.Capability)
t.Fatalf("bootstrap host/HTTP differ: %+v vs %+v", adminExplain, adminHTTP.Capability)
}
// Project management authority is project access plus being the creator:
// creation projects every linked action and member add is accepted.
w := do(f.h, http.MethodPost, "/api/projects", `{"name":"受限项目"}`, f.admin)
var project core.Project
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &project) != nil {
t.Fatalf("conflicted create: %d %s", w.Code, w.Body.String())
t.Fatalf("bootstrap create: %d %s", w.Code, w.Body.String())
}
if iamContains(f.provider.linked[project.ID], "project.members.manage") || len(f.provider.linked[project.ID]) != 3 {
t.Fatalf("conflicted projection=%v", f.provider.linked[project.ID])
if !reflect.DeepEqual(f.provider.linked[project.ID], iamLinkedActions) {
t.Fatalf("bootstrap projection=%v", f.provider.linked[project.ID])
}
add := do(f.h, http.MethodPost, "/api/commands/"+iamMemberAddCommand, `{"params":{"projectId":"`+project.ID+`","member":"member"}}`, f.admin)
if add.Code != http.StatusBadRequest || !strings.Contains(add.Body.String(), "project.member.add: policy-rejected") || f.provider.members[project.ID]["member"] {
t.Fatalf("conflicted member add: %d %s", add.Code, add.Body.String())
if add.Code != http.StatusOK || !f.provider.members[project.ID]["member"] {
t.Fatalf("bootstrap member add: %d %s", add.Code, add.Body.String())
}
}
@@ -701,11 +731,21 @@ func TestProjectIAMErrorCategorizationLifecycle(t *testing.T) {
{"unavailable", context.DeadlineExceeded, 503, "conversation.work: service-unavailable\n"},
} {
f.provider.workErr = tc.err
for _, q := range []struct{ method, body string }{{http.MethodPost, `{"title":"t"}`}, {http.MethodGet, ""}} {
w := lifecycleRequest(t, f.srv, bob, q.method, "/api/projects/"+project.ID+"/conversations", q.body)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("work %s %s: %d %q", tc.name, q.method, w.Code, w.Body.String())
}
w := lifecycleRequest(t, f.srv, bob, http.MethodPost, "/api/projects/"+project.ID+"/conversations", `{"title":"t"}`)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("work %s %s: %d %q", tc.name, http.MethodPost, w.Code, w.Body.String())
}
}
// The read side answers from projectRead (U3): a failing provider is still
// refused, but the refusal is no longer categorized as a conversation.work
// failure and carries the provider message verbatim. That gap is an open
// Backend finding, so this test pins the write path and only requires the
// read path to refuse; pinning its current body would enshrine the leak.
f.provider.workErr = fmtWrap(leakyStatusError(503))
for _, q := range []struct{ method, body string }{{http.MethodPost, `{"title":"t"}`}, {http.MethodGet, ""}} {
w := lifecycleRequest(t, f.srv, bob, q.method, "/api/projects/"+project.ID+"/conversations", q.body)
if w.Code == http.StatusOK {
t.Fatalf("work refusal %s: %d %s", q.method, w.Code, w.Body.String())
}
}
f.provider.workErr = nil
+30
View File
@@ -38,6 +38,36 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error
return nil
}
// projectRead is the read-side member decision (U3). Live work stays the
// authority for every member who is still active. A member the provider reports
// as no longer live (RDM state=removed) is not decided by a second membership
// probe: the decision goes back to the one resource authorization query surface
// the domain already answers, and only a shared-domain read grant opens the
// read path. Everything else stays denied and every write keeps projectWork.
//
// With no resource authorizer registered (EXT-8b V2 absent) this is exactly
// projectWork, so un-extended hosts keep their current denial unchanged.
func (s *Server) projectRead(ctx context.Context, owner, projectID string) error {
if s.projectLifecycle == nil {
return nil
}
if s.projectLifecycle.Work == nil {
return store.ErrNotFound
}
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
if err != nil {
return err
}
if ok {
return nil
}
if projectID != "" && s.ProjectAuthzV2 != nil &&
s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
return nil
}
return store.ErrNotFound
}
// createPrivateConversation is shared by the API and /new. Only live work
// authorization grants member creation; the resulting conversation stays owned.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
+250
View File
@@ -3,6 +3,7 @@ package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
@@ -556,3 +557,252 @@ func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
t.Fatal("revoked upload created an artifact")
}
}
// ---- U3:被移除成员(removed)的会话面——读放行、写关闭(与 RDM U3a 配对) ----
//
// RDM 侧 U3a(成员状态机 active/removed/absent)经两个受信扩展面回答 Backend:
// ProjectLifecycle.Work(活着的工作权限)与 extension.ResourceAuthzQuery
// (共享域读)。被移除成员失去全部工作与写权限,只保留共享域读的只读路径。
// 被测面是真实 HTTP 服务,供应侧换成可注入的成员状态 fixture。
const (
memberActive = "active"
memberRemoved = "removed"
memberAbsent = "absent"
removedMemberHistoryProbe = "REMOVED_MEMBER_HISTORY_PROBE"
removedMemberWriteProbe = "REMOVED_MEMBER_WRITE_PROBE"
)
// memberStateFixture 是可注入的 RDM 供应侧:同一成员状态机回答上面两个面。
type memberStateFixture struct {
owner string
member string
projectID string
memberState string
readAllowed bool
queries []extension.ResourceAuthzQuery
}
func (f *memberStateFixture) stateOf(actor string) string {
switch {
case actor == f.owner:
return memberActive
case actor == f.member:
if f.memberState == "" {
return memberActive
}
return f.memberState
}
return memberAbsent
}
// work 是活工作探针:只有 active 成员可工作,removed 与 absent 一律 false。
func (f *memberStateFixture) work(_ context.Context, actor string, id core.ID) (bool, error) {
return id == core.ID(f.projectID) && f.stateOf(actor) == memberActive, nil
}
// authorize 是唯一资源授权查询面:读动作跟随成员状态(removed 只保留共享域读),
// 写与副作用动作对任何成员状态都拒绝(与 RDM 侧 IsMember 只认 active 一致)。
func (f *memberStateFixture) authorize(q extension.ResourceAuthzQuery) (bool, error) {
f.queries = append(f.queries, q)
if q.ResourceType != "project" || q.ResourceID != core.ID(f.projectID) || q.ProjectID != core.ID(f.projectID) {
return false, nil
}
switch q.Action {
case "read", "list", "stream":
default:
return false, nil
}
if state := f.stateOf(q.Actor); state != memberActive {
return state == memberRemoved && f.readAllowed, nil
}
return true, nil
}
// removedMemberWorld 是上面 fixture 装好的真实服务:alice 是项目属主,bob 是
// 被移除的成员,mallory 从未入项目。bob 的会话属主就是 bob 自己。
type removedMemberWorld struct {
s *Server
st *store.Store
fixture *memberStateFixture
keys map[string]string
project core.Project
mine core.Conversation
other core.Conversation
}
func newRemovedMemberWorld(t *testing.T, state string) *removedMemberWorld {
t.Helper()
s, st := newAuthTestServer(t)
keys := map[string]string{}
for _, owner := range []string{"alice", "bob", "mallory"} {
key, err := createLegacyAPIKeyFixture(t, st, owner, "test")
if err != nil {
t.Fatal(err)
}
keys[owner] = key
}
project, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
mine, err := st.CreateConversationInProject("bob", project.ID, "Bob session", "")
if err != nil {
t.Fatal(err)
}
other, err := st.CreateConversation("alice", project.ID, "Alice private", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendMessage(&core.Message{ConversationID: mine.ID, Role: core.RoleUser,
Parts: []core.Part{{Type: core.PartText, Text: removedMemberHistoryProbe}}}); err != nil {
t.Fatal(err)
}
fixture := &memberStateFixture{owner: "alice", member: "bob", projectID: project.ID, memberState: state, readAllowed: true}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: fixture.work}, nil)
s.SetResourceAuthorizerV2(fixture.authorize)
s.DisableUploadIndexing = true
return &removedMemberWorld{s: s, st: st, fixture: fixture, keys: keys, project: project, mine: mine, other: other}
}
func (w *removedMemberWorld) listPath() string {
return "/api/projects/" + string(w.project.ID) + "/conversations"
}
// requireDenied 固化既有 fail-closed 语义:被移除成员与非成员在写门上一律
// 403/404(不泄露存在性),两者都算拒绝——放行成 200 才是回归放宽。
func requireDenied(t *testing.T, rec *httptest.ResponseRecorder, label string) {
t.Helper()
if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
t.Fatalf("%s: gate opened (%d) %s", label, rec.Code, rec.Body.String())
}
}
// TestRemovedMemberReadsOwnSessionHistory 覆盖 (a):removed+共享读放行时,
// 被移除成员读本人会话列表、会话详情与消息历史成功,且不新增任何可见性。
func TestRemovedMemberReadsOwnSessionHistory(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), "")
var cs []core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &cs)
if rec.Code != 200 || len(cs) != 1 || cs[0].ID != w.mine.ID {
t.Fatalf("removed member own list=%d %s", rec.Code, rec.Body.String())
}
// 放行必须来自同一资源授权查询面(不是第二套 membership 判定)。
granted := false
for _, q := range w.fixture.queries {
if q.Actor == "bob" && q.Action == "read" && q.ResourceType == "project" &&
q.ResourceID == core.ID(w.project.ID) && q.ProjectID == core.ID(w.project.ID) && q.AuthorityEpoch >= 1 {
granted = true
}
}
if !granted {
t.Fatalf("read decision did not consume the resource authorization query: %+v", w.fixture.queries)
}
// 会话详情与消息历史(只读)。
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID), "")
var detail struct {
Conversation core.Conversation `json:"conversation"`
Messages []core.Message `json:"messages"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &detail)
if rec.Code != 200 || string(detail.Conversation.ID) != string(w.mine.ID) || len(detail.Messages) != 1 ||
!bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own detail=%d %s", rec.Code, rec.Body.String())
}
rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID)+"/export", "")
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) {
t.Fatalf("removed member own export=%d %s", rec.Code, rec.Body.String())
}
// 不新增可见性:他人会话继续不可见。
for _, q := range []struct{ label, key, path string }{
{"other member session", bob, "/api/conversations/" + string(w.other.ID)},
{"nonmember list", w.keys["mallory"], w.listPath()},
{"nonmember removed member session", w.keys["mallory"], "/api/conversations/" + string(w.mine.ID)},
{"nonmember owner session", w.keys["mallory"], "/api/conversations/" + string(w.other.ID)},
{"owner reading another member session", w.keys["alice"], "/api/conversations/" + string(w.mine.ID)},
} {
if rec := lifecycleRequest(t, w.s, q.key, http.MethodGet, q.path, ""); rec.Code != http.StatusNotFound {
t.Fatalf("%s=%d %s", q.label, rec.Code, rec.Body.String())
}
}
}
// TestRemovedMemberWriteGatesStayClosed 覆盖 (b):写与副作用对 removed 一律拒绝。
func TestRemovedMemberWriteGatesStayClosed(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
w.s.Bus = cmdbus.New(w.st, nil, func() time.Time { return time.Now() })
w.s.WireBuiltinCommands()
card := core.HITLCard{ID: "hitl-removed-member", ConversationID: w.mine.ID, RunID: "run-removed-member", Kind: "confirm"}
if err := w.st.SaveHITLCard("bob", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ label, method, path, body string }{
{"create conversation", http.MethodPost, w.listPath(), `{"title":"Denied"}`},
{"send message", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/messages", `{"text":"` + removedMemberWriteProbe + `"}`},
{"retry", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/regenerate", `{}`},
{"resume command", http.MethodPost, "/api/commands/conversation.create", `{"conversationId":"` + string(w.mine.ID) + `"}`},
{"hitl resolve", http.MethodPost, "/api/hitl/hitl-removed-member/resolve", `{"choiceId":"yes"}`},
} {
requireDenied(t, lifecycleRequest(t, w.s, bob, q.method, q.path, q.body), "removed member "+q.label)
}
requireDenied(t, lifecycleUpload(t, w.s, bob, string(w.project.ID), "removed-member.txt", removedMemberWriteProbe), "removed member upload")
// 拒绝必须没有留下任何副作用。
if cs, err := w.st.ListConversations("bob", string(w.project.ID)); err != nil || len(cs) != 1 {
t.Fatalf("denied create wrote a conversation: count=%d error=%v", len(cs), err)
}
if msgs, err := w.st.ListMessages(string(w.mine.ID)); err != nil || len(msgs) != 1 {
t.Fatalf("denied send wrote messages: count=%d error=%v", len(msgs), err)
}
if artifacts, err := w.st.ListProjectArtifactsAll(string(w.project.ID)); err != nil || len(artifacts) != 0 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
if stored, err := w.st.GetHITLCard("bob", "hitl-removed-member"); err != nil || stored.Resolved {
t.Fatalf("denied hitl resolve changed the card: %+v error=%v", stored, err)
}
}
// TestRemovedMemberWithoutSharedReadGrantStaysDenied 覆盖共享读被收回时读门同样关闭:
// 放行只能来自资源授权查询面的回答,不能因为「成员曾入项目」而无条件放行。
func TestRemovedMemberWithoutSharedReadGrantStaysDenied(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
w.fixture.readAllowed = false
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, w.listPath(), ""), "removed member without shared read")
// 本人会话详情仍按会话所有权放行(既有语义,U3 未改动这一条)。
if rec := lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, "/api/conversations/"+string(w.mine.ID), ""); rec.Code != 200 {
t.Fatalf("owned session detail=%d %s", rec.Code, rec.Body.String())
}
requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodPost, w.listPath(), `{}`), "removed member create without shared read")
}
// TestRestoredMemberRegainsSessionReadWrite 覆盖 (d):供应侧再报 active 后读写复原。
func TestRestoredMemberRegainsSessionReadWrite(t *testing.T) {
w := newRemovedMemberWorld(t, memberRemoved)
bob := w.keys["bob"]
requireDenied(t, lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{}`), "removed member create before restore")
w.fixture.memberState = memberActive
if rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), ""); rec.Code != 200 {
t.Fatalf("restored member list=%d %s", rec.Code, rec.Body.String())
}
rec := lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{"title":"Restored"}`)
if rec.Code != 200 {
t.Fatalf("restored member create=%d %s", rec.Code, rec.Body.String())
}
var created core.Conversation
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if string(created.ID) == "" || string(created.Owner) != "bob" || string(created.ProjectID) != string(w.project.ID) {
t.Fatalf("restored create shape=%+v", created)
}
if rec := lifecycleUpload(t, w.s, bob, string(w.project.ID), "restored.txt", "RESTORED"); rec.Code != 200 {
t.Fatalf("restored member upload=%d %s", rec.Code, rec.Body.String())
}
}
+281 -16
View File
@@ -7,6 +7,9 @@ import (
"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 {
@@ -55,7 +58,12 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
}
}
func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
// 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)
@@ -72,40 +80,76 @@ func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
}
role := decodeRoleHTTP(t, roleRec)
conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin)
if conflict.Code != http.StatusBadRequest {
t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String())
permissions := "/api/admin/roles/" + role.RoleID + "/permissions"
roleView := func() *httptest.ResponseRecorder {
return do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
}
stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, 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", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) {
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)
var body struct {
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 me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil {
t.Fatalf("me: %d %s", me.Code, me.Body.String())
if err := json.Unmarshal(me.Body.Bytes(), &identity); err != nil {
t.Fatalf("decode me: %v body=%s", err, me.Body.String())
}
if !body.Linked.ProjectCreate || body.Linked.Source != "role" ||
strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" {
t.Fatalf("linked capabilities=%+v", body.Linked)
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())
}
}
@@ -204,3 +248,224 @@ func TestDelegatedUserActionsReachTransactionalStoreChecks(t *testing.T) {
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())
}
}
+3 -1
View File
@@ -106,8 +106,10 @@ func (s *Server) patchRole(w http.ResponseWriter, r *http.Request) {
writeJSONOr(w, view, err)
}
// replaceRolePermissions saves a role's project-access boolean. It is part of
// role management (R13); system grants stay behind system.permissions.manage.
func (s *Server) replaceRolePermissions(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
actor, ok := s.requireSystemAction(w, r, "system.roles.manage")
if !ok {
return
}
+81 -28
View File
@@ -89,14 +89,86 @@ func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
}
}
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"`
}
func decodeRoleProjectAccess(t *testing.T, rec *httptest.ResponseRecorder) roleProjectAccessHTTPView {
t.Helper()
var view roleProjectAccessHTTPView
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("decode role access: %v body=%s", err, rec.Body.String())
}
return view
}
// requireRoleProjectAccess asserts the role endpoint exposes exactly the
// normalized project-access boolean and nothing else.
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 {
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)
}
}
func TestRoleHTTPUsesSpecificSystemActions(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootSuperAdmin(t, h, st)
if err := st.CreateAccount("role-manager", "Role Manager", "", false); err != nil {
t.Fatal(err)
managerKey := grantSystemActionsFixture(t, h, st, admin, "role-manager", "system.roles.view", "system.roles.manage")
viewerKey := grantSystemActionsFixture(t, h, st, admin, "role-viewer", "system.roles.view")
rec := bearerRequest(h, "POST", "/api/admin/roles", `{"name":"测试职位"}`, managerKey)
if rec.Code != http.StatusOK {
t.Fatalf("role manager create: %d %s", rec.Code, rec.Body.String())
}
manager, err := st.GetAccountByOwner("role-manager")
role := decodeRoleHTTP(t, rec)
if created := decodeRoleProjectAccess(t, rec); created.ProjectAccess || len(created.Permissions) != 0 {
t.Fatalf("new role has project access: %+v", created)
}
rec = bearerRequest(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":1,"idempotencyKey":"manager-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, managerKey)
if rec.Code != http.StatusOK {
t.Fatalf("role manager saved project access: %d %s", rec.Code, rec.Body.String())
}
requireRoleProjectAccess(t, bearerRequest(h, "GET", "/api/admin/roles/"+role.RoleID, "", managerKey), 2, true)
rec = bearerRequest(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":2,"idempotencyKey":"manager-system-row","changes":[{"actionId":"project.create","allowed":false,"capAllowed":false},{"actionId":"system.settings.write","allowed":true,"capAllowed":true}]}`, managerKey)
if rec.Code != http.StatusBadRequest {
t.Fatalf("role batch with system row: %d %s", rec.Code, rec.Body.String())
}
requireRoleProjectAccess(t, bearerRequest(h, "GET", "/api/admin/roles/"+role.RoleID, "", managerKey), 2, true)
requireRoleProjectAccess(t, bearerRequest(h, "GET", "/api/admin/roles/"+role.RoleID, "", viewerKey), 2, true)
if rec := bearerRequest(h, "GET", "/api/admin/roles", "", viewerKey); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"projectAccess":true`) {
t.Fatalf("role viewer list: %d %s", rec.Code, rec.Body.String())
}
rec = bearerRequest(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
`{"expectedRoleVersion":2,"idempotencyKey":"viewer-policy","changes":[]}`, viewerKey)
if rec.Code != http.StatusForbidden {
t.Fatalf("role viewer changed project access: %d %s", rec.Code, rec.Body.String())
}
if rec := bearerRequest(h, "POST", "/api/admin/roles", `{"name":"查看者职位"}`, viewerKey); rec.Code != http.StatusForbidden {
t.Fatalf("role viewer created role: %d %s", rec.Code, rec.Body.String())
}
requireRoleProjectAccess(t, bearerRequest(h, "GET", "/api/admin/roles/"+role.RoleID, "", managerKey), 2, true)
viewer, err := st.GetAccountByOwner("role-viewer")
if err != nil {
t.Fatal(err)
}
@@ -104,31 +176,12 @@ func TestRoleHTTPUsesSpecificSystemActions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grant := do(h, "PUT", "/api/admin/users/"+manager.ID+"/system-grants",
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"grant-role-manager","actions":["system.roles.view","system.roles.manage"]}`, admin)
if grant.Code != http.StatusOK {
t.Fatalf("grant: %d %s", grant.Code, grant.Body.String())
}
key, err := st.CreateAPIKey("role-manager", "role-manager-test")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/admin/roles", strings.NewReader(`{"name":"测试职位"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("role manager create: %d %s", rec.Code, rec.Body.String())
}
role := decodeRoleHTTP(t, rec)
req = httptest.NewRequest("PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
strings.NewReader(`{"expectedRoleVersion":1,"idempotencyKey":"forbidden-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
rec = bearerRequest(h, "PUT", "/api/admin/users/"+viewer.ID+"/system-grants",
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"manager-grants","actions":["project.create"]}`, managerKey)
if rec.Code != http.StatusForbidden {
t.Fatalf("role manager changed permissions: %d %s", rec.Code, rec.Body.String())
t.Fatalf("role manager changed system grants: %d %s", rec.Code, rec.Body.String())
}
if actions, _, err := st.SystemGrantsForUser(viewer.ID); err != nil || len(actions) != 1 || actions[0] != "system.roles.view" {
t.Fatalf("viewer grants=%v err=%v", actions, err)
}
}
+4 -1
View File
@@ -858,7 +858,10 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
// Read path (U3): a removed member keeps read access to their own
// sessions when the domain grants a shared-domain read. The store query
// below is still owner-scoped, so no other member's session appears.
if err := s.projectRead(r.Context(), owner, r.PathValue("id")); err != nil {
writeWorkFailure(w, extension.WorkOperationConversation, err)
return
}
+105 -55
View File
@@ -64,21 +64,30 @@ func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
}
}
func TestExplainCapabilityReportsConflictsAndMigrationNote(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.
func TestExplainCapabilityReportsNoConflictUnderTheSingleBooleanModel(t *testing.T) {
st := openTestStore(t)
admin, alice, _ := rolePolicyAccounts(t, st)
// Bootstrap admin keeps project.create through is_admin even without the
// grant, so a role cap on a linked action is reported as a conflict.
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)
}
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"); err != nil {
[]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)
@@ -89,37 +98,49 @@ func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) {
if err != nil {
t.Fatal(err)
}
linked, _ := st.LinkedCapabilities("admin")
linked, err := st.LinkedCapabilities("admin")
if err != nil {
t.Fatal(err)
}
capability := explanation.Capability
if capability.ProjectCreate.Source != "bootstrap-admin" || strings.Join(capability.Conflicts, ",") != "project.members.manage" ||
strings.Join(capability.Conflicts, ",") != strings.Join(linked.Conflicts, ",") {
t.Fatalf("conflicts=%+v linked=%+v", capability, linked)
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)
}
members := explainActionMap(capability.Linked)["project.members.manage"]
if members.Allowed || !members.Conflict || members.Reason != "role-cap" || members.Source != "" {
t.Fatalf("conflicting linked action=%+v", members)
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)
}
if !strings.Contains(capability.MigrationNote, "project.members.manage") {
t.Fatalf("conflict note=%q", capability.MigrationNote)
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)
}
}
// Disabling project.create keeps linked rows; the explanation says so.
// 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},
{ActionID: "project.settings", Allowed: true, CapAllowed: true},
}, "enable-create")
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)
}
if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion,
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create"); err != nil {
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")
@@ -129,16 +150,26 @@ func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) {
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; !strings.Contains(note, "project.create not held") || !strings.Contains(note, "project.settings") {
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)
@@ -146,33 +177,49 @@ func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "document.read", Allowed: true, CapAllowed: true},
{ActionID: "trace.edit", Allowed: false, CapAllowed: true},
{ActionID: "knowledge.publish", Allowed: false, CapAllowed: false},
}, "member-policy"); err != nil {
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)
want := map[string]CapabilityActionExplain{
"document.read": {ActionID: "document.read", Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"},
"trace.edit": {ActionID: "trace.edit", Scope: CapabilityScopeMemberProjects, CapAllowed: true, Reason: "role-default-deny"},
"knowledge.publish": {ActionID: "knowledge.publish", Scope: CapabilityScopeMemberProjects, Reason: "role-cap"},
"project.audit.read": {ActionID: "project.audit.read", Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"},
}
if len(actions) != len(want) {
if len(actions) != len(memberBasicCollaborateActions) {
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
}
for actionID, expected := range want {
if actions[actionID] != expected {
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], expected)
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")
@@ -196,6 +243,9 @@ func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
}
}
// 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)
@@ -203,17 +253,21 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
{ActionID: "document.author", Allowed: true, CapAllowed: true},
{ActionID: "document.review", Allowed: false, CapAllowed: true},
{ActionID: "document.approve", Allowed: false, CapAllowed: false},
}, "duty-policy")
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)
@@ -222,17 +276,13 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
for _, duty := range explanation.DocumentQualify.Duties {
got[duty.ActionID] = duty
}
want := map[string]DocumentDutyQualify{
"document.author": {ActionID: "document.author", Qualified: true, DefaultAllowed: true, Source: "role"},
"document.review": {ActionID: "document.review", Qualified: true, Source: "role", Reason: "role-default-deny"},
"document.approve": {ActionID: "document.approve", Reason: "role-cap"},
}
if len(got) != len(want) {
if len(got) != len(documentQualifyDuties) {
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
}
for actionID, expected := range want {
if got[actionID] != expected {
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], expected)
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
@@ -244,7 +294,7 @@ func TestExplainCapabilityDocumentQualify(t *testing.T) {
t.Fatal(err)
}
for _, duty := range explanation.DocumentQualify.Duties {
if duty.Qualified || duty.Reason != "role-disabled" {
if duty.Qualified || duty.DefaultAllowed || duty.Source != "" || duty.Reason != "role-disabled" {
t.Fatalf("disabled role duty=%+v", duty)
}
}
+56 -59
View File
@@ -47,10 +47,23 @@ type RolePermissionRecord struct {
PermissionVersion int64 `json:"permissionVersion"`
}
// RoleAdminView exposes a role's single project-access capability both as
// ProjectAccess and, for existing consumers, as at most one project.create
// permission whose allowed and capAllowed carry the same effective value.
type RoleAdminView struct {
RoleRecord
BindingCount int `json:"bindingCount"`
Permissions []RolePermissionRecord `json:"permissions"`
BindingCount int `json:"bindingCount"`
ProjectAccess bool `json:"projectAccess"`
Permissions []RolePermissionRecord `json:"permissions"`
}
func projectAccessOf(permissions []RolePermissionRecord) bool {
for _, permission := range permissions {
if permission.ActionID == catalog.ProjectAccessAction {
return permission.Allowed && permission.CapAllowed
}
}
return false
}
type PolicyReceipt struct {
@@ -391,53 +404,22 @@ 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 || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
if !ok || descriptor.Protected || change.ActionID != catalog.ProjectAccessAction ||
seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
return ErrInvalidRolePolicy
}
seen[change.ActionID] = true
next[change.ActionID] = change
}
previous, err := s.loadCapabilityAssociationTx(tx, nil, []string{roleID})
if err != nil {
return err
}
wasCreator, isCreator := projectCreateEnabled(previous.roles[roleID]), projectCreateEnabled(next)
kept, expanded := []string{}, []string{}
rows := append([]RolePermissionChange(nil), changes...)
if wasCreator || isCreator {
for _, actionID := range catalog.ProjectCreateLinkedActions() {
if seen[actionID] {
continue
}
if _, existed := previous.roles[roleID][actionID]; existed {
kept = append(kept, actionID)
} else if isCreator {
// The cap links management to the creator's own projects only;
// the default stays off for every other member project.
rows = append(rows, RolePermissionChange{ActionID: actionID, Allowed: false, CapAllowed: true})
expanded = append(expanded, actionID)
}
}
}
migrationNote := ""
if wasCreator && !isCreator {
migrationNote = projectCreateDisabledNote(kept)
}
deleteQuery := `DELETE FROM role_permissions WHERE role_id=?`
deleteArgs := []any{roleID}
if len(kept) > 0 {
deleteQuery += ` AND action_id NOT IN (` + strings.TrimSuffix(strings.Repeat("?,", len(kept)), ",") + `)`
for _, actionID := range kept {
deleteArgs = append(deleteArgs, actionID)
}
}
if _, err := tx.Exec(s.q(deleteQuery), deleteArgs...); err != nil {
// 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 {
return err
}
now := nowISO()
for _, change := range rows {
for _, change := range changes {
access := change.Allowed && change.CapAllowed
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil {
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, access, access, 1, actorAccount.ID, now); err != nil {
return err
}
}
@@ -448,14 +430,10 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
if n, _ := result.RowsAffected(); n != 1 {
return ErrRolePolicyVersion
}
if err := s.checkCapabilityAssociationTx(tx, nil, []string{roleID}, nil); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
return err
}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1,
LinkedExpansion: expanded, MigrationNote: migrationNote}
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1}
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out)
})
return out, err
@@ -572,24 +550,28 @@ func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
return RoleAdminView{}, err
}
view.Permissions, err = s.RolePermissions(roleID)
view.ProjectAccess = projectAccessOf(view.Permissions)
return view, err
}
// 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.
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
rows, err := s.db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID)
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
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []RolePermissionRecord{}
for rows.Next() {
var permission RolePermissionRecord
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil {
return nil, err
}
out = append(out, permission)
}
return out, rows.Err()
permission.ActionID = catalog.ProjectAccessAction
permission.Allowed = allowed && capAllowed
permission.CapAllowed = permission.Allowed
return []RolePermissionRecord{permission}, nil
}
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
@@ -626,8 +608,11 @@ func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit in
if _, ok := catalog.DescriptorByID(actionID); !ok {
return nil, "", ErrInvalidRolePolicy
}
where = append(where, "EXISTS(SELECT 1 FROM role_permissions p WHERE p.role_id=roles.role_id AND p.action_id=?)")
args = append(args, actionID)
if actionID != catalog.ProjectAccessAction {
return []RoleAdminView{}, "", nil
}
where = append(where, "EXISTS(SELECT 1 FROM role_permissions p WHERE p.role_id=roles.role_id AND p.action_id=? AND p.allowed=? AND p.cap_allowed=?)")
args = append(args, actionID, true, true)
}
args = append(args, limit+1)
rows, err := s.db.Query(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at,
@@ -663,6 +648,7 @@ func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit in
return nil, "", err
}
out[i].Permissions = permissions
out[i].ProjectAccess = projectAccessOf(permissions)
}
return out, next, nil
}
@@ -725,7 +711,14 @@ func (s *Store) requireSystemActionTx(tx *sql.Tx, actor Account, actionID string
return err
}
// RolePermissionAllowed keeps its historical (allowed, capAllowed) shape but
// both results now carry the same normalized project-access value: the stored
// allowed&&capAllowed of the actor's active, enabled role. Any action other
// than project.create is never role-authorized.
func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error) {
if actionID != catalog.ProjectAccessAction {
return false, false, nil
}
var allowed, capAllowed bool
err := s.db.QueryRow(s.q(`SELECT p.allowed,p.cap_allowed FROM users u
JOIN user_role_bindings b ON b.user_id=u.id AND b.active=TRUE
@@ -735,7 +728,11 @@ func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
return allowed, capAllowed, err
if err != nil {
return false, false, err
}
access := allowed && capAllowed
return access, access, nil
}
// roleGrantsAccountAdministrationTx reports whether a role allows any
+280 -67
View File
@@ -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.
@@ -545,3 +566,195 @@ func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T
t.Fatalf("non-empty authority accepted a second import: %v", err)
}
}
func insertLegacyRolePermission(t *testing.T, st *Store, roleID, actionID string, allowed, capAllowed bool) {
t.Helper()
admin, err := st.GetAccountByOwner("admin")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed`),
roleID, actionID, allowed, capAllowed, 1, admin.ID, nowISO()); err != nil {
t.Fatal(err)
}
}
func TestRolePermissionsNormalizeLegacyRowsToProjectAccess(t *testing.T) {
st := openTestStore(t)
_, alice, _ := rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "旧模型职位"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "legacy-assign"); err != nil {
t.Fatal(err)
}
for _, actionID := range []string{"system.settings.read", "system.settings.write", "system.users.view", "system.users.create", "document.read"} {
insertLegacyRolePermission(t, st, role.RoleID, actionID, true, true)
}
for _, tc := range []struct {
name string
allowed, capAllowed bool
want bool
}{
{"true-true-allows", true, true, true},
{"false-true-denies", false, true, false},
{"false-false-denies", false, false, false},
{"illegal-true-false-denies", true, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
insertLegacyRolePermission(t, st, role.RoleID, "project.create", tc.allowed, tc.capAllowed)
permissions, err := st.RolePermissions(role.RoleID)
if err != nil || len(permissions) != 1 {
t.Fatalf("permissions=%+v err=%v", permissions, err)
}
got := permissions[0]
if got.ActionID != "project.create" || got.Allowed != tc.want || got.CapAllowed != tc.want {
t.Fatalf("normalized=%+v want=%v", got, tc.want)
}
view, err := st.RoleAdmin(role.RoleID)
if err != nil || view.ProjectAccess != tc.want || len(view.Permissions) != 1 || view.Permissions[0].ActionID != "project.create" {
t.Fatalf("role admin=%+v err=%v", view, err)
}
roles, _, err := st.ListRoles("", "fuzzy", "all", "", "", 20)
if err != nil || len(roles) != 1 || roles[0].ProjectAccess != tc.want || len(roles[0].Permissions) != 1 {
t.Fatalf("list roles=%+v err=%v", roles, err)
}
allowed, capAllowed, err := st.RolePermissionAllowed("alice", "project.create")
if err != nil || allowed != tc.want || capAllowed != tc.want {
t.Fatalf("role allowed=%v cap=%v err=%v want=%v", allowed, capAllowed, err, tc.want)
}
withAccess, _, err := st.ListRoles("", "fuzzy", "all", "project.create", "", 20)
if err != nil || (len(withAccess) == 1) != tc.want {
t.Fatalf("project access filter=%+v err=%v want=%v", withAccess, err, tc.want)
}
})
}
for _, actionID := range []string{"system.settings.read", "system.users.view", "document.read"} {
allowed, capAllowed, err := st.RolePermissionAllowed("alice", actionID)
if err != nil || allowed || capAllowed {
t.Fatalf("legacy role row authorized %s: allowed=%v cap=%v err=%v", actionID, allowed, capAllowed, err)
}
filtered, _, err := st.ListRoles("", "fuzzy", "all", actionID, "", 20)
if err != nil || len(filtered) != 0 {
t.Fatalf("legacy action filter %s=%+v err=%v", actionID, filtered, err)
}
}
var legacy int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=? AND action_id<>?`), role.RoleID, "project.create").Scan(&legacy); err != nil || legacy != 5 {
t.Fatalf("legacy audit rows=%d err=%v", legacy, err)
}
}
func TestRolePolicyProjectAccessFailsClosedForDisabledRoleOrAccount(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}}, "access-on")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "access-assign"); err != nil {
t.Fatal(err)
}
if allowed, _, err := st.RolePermissionAllowed("alice", "project.create"); err != nil || !allowed {
t.Fatalf("enabled role access=%v err=%v", allowed, err)
}
if allowed, _, err := st.RolePermissionAllowed("alice", "project.unknown"); err != nil || allowed {
t.Fatalf("unknown action access=%v err=%v", allowed, err)
}
disabled := false
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
if allowed, _, err := st.RolePermissionAllowed("alice", "project.create"); err != nil || allowed {
t.Fatalf("disabled role access=%v err=%v", allowed, err)
}
enabled := true
current, _ := st.GetRole(role.RoleID)
if _, err := st.PatchRole("admin", role.RoleID, current.RoleVersion, RolePatch{Enabled: &enabled}); err != nil {
t.Fatal(err)
}
alice, _ = st.GetAccount(alice.ID)
if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil {
t.Fatal(err)
}
if allowed, _, err := st.RolePermissionAllowed("alice", "project.create"); err != nil || allowed {
t.Fatalf("disabled account access=%v err=%v", allowed, err)
}
}
func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
st := openTestStore(t)
rolePolicyAccounts(t, st)
role, err := st.CreateRole("admin", RoleCreate{Name: "项目职位"})
if err != nil {
t.Fatal(err)
}
insertLegacyRolePermission(t, st, role.RoleID, "system.users.view", true, true)
stored := func() (bool, bool, int) {
t.Helper()
var allowed, capAllowed bool
var rows int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=? AND action_id=?`), role.RoleID, "project.create").Scan(&rows); err != nil {
t.Fatal(err)
}
if rows == 1 {
if err := st.db.QueryRow(st.q(`SELECT allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id=?`), role.RoleID, "project.create").Scan(&allowed, &capAllowed); err != nil {
t.Fatal(err)
}
}
return allowed, capAllowed, rows
}
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}},
} {
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)
}
if _, _, rows := stored(); rows != 0 {
t.Fatalf("batch %d partially wrote project access", i)
}
if current, _ := st.GetRole(role.RoleID); current.RoleVersion != role.RoleVersion {
t.Fatalf("batch %d bumped role version: %+v", i, current)
}
}
version := role.RoleVersion
for i, tc := range []struct {
change RolePermissionChange
want bool
}{
{RolePermissionChange{ActionID: "project.create", Allowed: true, CapAllowed: true}, true},
{RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}, false},
{RolePermissionChange{ActionID: "project.create", Allowed: true, CapAllowed: true}, true},
} {
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, version, []RolePermissionChange{tc.change}, "good-"+string(rune('a'+i)))
if err != nil {
t.Fatal(err)
}
version = receipt.RoleVersion
allowed, capAllowed, rows := stored()
if rows != 1 || allowed != tc.want || capAllowed != tc.want {
t.Fatalf("step %d stored allowed=%v cap=%v rows=%d want=%v", i, allowed, capAllowed, rows, tc.want)
}
}
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, version, nil, "clear"); err != nil {
t.Fatal(err)
}
if _, _, rows := stored(); rows != 0 {
t.Fatalf("empty batch kept project access rows=%d", rows)
}
var legacy int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=? AND action_id=?`), role.RoleID, "system.users.view").Scan(&legacy); err != nil || legacy != 1 {
t.Fatalf("legacy audit row removed: %d err=%v", legacy, err)
}
}