Merge the lifecycle role and project-state work into Backend main

Why: the project access and lifecycle units (U1 role narrowing to a single
project-access boolean, U2 creator authority, U3 member history, U4 project
work states) were implemented and pushed on this line but never landed on
Backend main, so Dev's pinned Backend does not contain them.

Evidence: merge is conflict-free against current origin/main; full
vet-and-test runs on the merge commit.

Impact: Backend only. RDM side is delivered separately.

Refs: Dev PR #634.
This commit is contained in:
weichen
2026-10-21 03:47:35 +00:00
17 changed files with 1283 additions and 316 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)
}
}
+5 -2
View File
@@ -858,8 +858,11 @@ 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 {
writeWorkFailure(w, extension.WorkOperationConversation, err)
// 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 {
writeProjectErr(w, err)
return
}
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
+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)
}
}