Files
Agent-System-Backend/internal/server/project_iam_integration_test.go
T
weichen 4030449f4b Report honest receipts when handlers err after side effects
Why: F10 - any handler error became a no-side-effect rejected receipt
and an audit-store failure dropped the generated operation ID, losing
the reconciliation handle and inviting unsafe retries.
Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown,
audit-failure-keeps-ID and confirmation-idempotency cases added.
Impact: unknown receipts now carry the trusted operation ID via the
U7A constructor; rejected stays ID-free; no auto-replay anywhere.
Refs: feature/agent-stack-audit-remediation-b1 U7B
2026-10-21 16:55:01 +00:00

921 lines
38 KiB
Go

package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
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"}
// iamCapabilityHost mirrors the in-process dispatch Host: it is bound to one
// actor and explains only that actor from the Backend authority snapshot.
type iamCapabilityHost struct {
extension.Host
srv *Server
owner string
}
func (h iamCapabilityHost) PluginID() string { return "rdm" }
func (h iamCapabilityHost) Owner() string { return h.owner }
func (h iamCapabilityHost) explanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) {
if err := ctx.Err(); err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
explanation, err := h.srv.authorizer.ExplainCapabilities(h.owner)
if err != nil {
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
}
return explanation, extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion,
UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch,
RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled}, nil
}
func iamActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain {
out := make([]extension.CapabilityActionExplain, 0, len(in))
for _, action := range in {
out = append(out, extension.CapabilityActionExplain(action))
}
return out
}
func (h iamCapabilityHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) {
explanation, subject, err := h.explanation(ctx)
if err != nil {
return extension.CapabilityExplain{}, err
}
capability := explanation.Capability
return extension.CapabilityExplain{CapabilitySubject: subject,
ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate),
Linked: iamActionExplains(capability.Linked),
Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil
}
func (h iamCapabilityHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) {
explanation, subject, err := h.explanation(ctx)
if err != nil {
return extension.MemberBasicCollaborate{}, err
}
return extension.MemberBasicCollaborate{CapabilitySubject: subject, Actions: iamActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil
}
func (h iamCapabilityHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) {
explanation, subject, err := h.explanation(ctx)
if err != nil {
return extension.DocumentQualify{}, err
}
duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties))
for _, duty := range explanation.DocumentQualify.Duties {
duties = append(duties, extension.DocumentDutyQualify(duty))
}
return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil
}
// iamProjectProvider is a domain lifecycle provider: creation projects the
// creator's linked capabilities, membership answers Work, and member add is
// limited to the Host-bound creator's own projects.
type iamProjectProvider struct {
st *store.Store
members map[string]map[string]bool
linked map[string][]string
archived map[string]bool
workActors []string
createErr error
workErr error
memberErr error
}
func (p *iamProjectProvider) lifecycle() extension.ProjectLifecycle {
return extension.ProjectLifecycle{List: p.list, Create: p.create, Rename: p.rename, Archive: p.archive, Work: p.work}
}
func (p *iamProjectProvider) explain(ctx context.Context, h extension.Host) (extension.CapabilityExplain, error) {
provider, ok := h.(extension.CapabilityExplainProvider)
if !ok {
return extension.CapabilityExplain{}, extension.WorkFailure{Reason: extension.WorkFailureServiceUnavailable}
}
return provider.CurrentCapabilityExplain(ctx)
}
func (p *iamProjectProvider) create(ctx context.Context, h extension.Host, name string) (core.Project, error) {
if p.createErr != nil {
return core.Project{}, p.createErr
}
explain, err := p.explain(ctx, h)
if err != nil {
return core.Project{}, err
}
if !explain.ProjectCreate.Allowed {
return core.Project{}, extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}
}
owned, err := p.st.ListProjects(h.Owner())
if err != nil {
return core.Project{}, err
}
for _, existing := range owned {
if existing.Name == name {
return core.Project{}, store.ErrConflict
}
}
project, err := p.st.CreateProjectAccountChecked(h.Owner(), name, false)
if err != nil {
return core.Project{}, err
}
linked := []string{}
for _, action := range explain.Linked {
if action.Allowed && action.LinkedVia == "project.create" && action.Scope == extension.CapabilityScopeOwnProjects {
linked = append(linked, action.ActionID)
}
}
p.members[project.ID] = map[string]bool{h.Owner(): true}
p.linked[project.ID] = linked
return project, nil
}
func (p *iamProjectProvider) list(_ context.Context, h extension.Host) ([]core.Project, error) {
ids := make([]string, 0, len(p.members))
for id, members := range p.members {
if members[h.Owner()] && !p.archived[id] {
ids = append(ids, id)
}
}
sort.Strings(ids)
out := make([]core.Project, 0, len(ids))
for _, id := range ids {
project, err := p.st.ProjectMetadata(id)
if err != nil {
return nil, err
}
out = append(out, project)
}
return out, nil
}
func (p *iamProjectProvider) rename(_ context.Context, h extension.Host, id, name string) (core.Project, error) {
if err := p.st.RenameProjectAccountChecked(h.Owner(), id, name, false); err != nil {
return core.Project{}, err
}
return p.st.GetProject(h.Owner(), id)
}
func (p *iamProjectProvider) archive(_ context.Context, h extension.Host, id string) error {
if _, err := p.st.GetProject(h.Owner(), id); err != nil {
return err
}
p.archived[id] = true
return nil
}
func (p *iamProjectProvider) work(_ context.Context, actor string, id core.ID) (bool, error) {
p.workActors = append(p.workActors, actor)
if p.workErr != nil {
return false, p.workErr
}
return !p.archived[id] && p.members[id][actor], nil
}
// addMember refuses any project the Host-bound actor did not create with the
// same not-found as an unknown project or account.
func (p *iamProjectProvider) addMember(ctx context.Context, h extension.Host, projectID, member string) error {
if p.memberErr != nil {
return p.memberErr
}
if _, err := p.st.GetProject(h.Owner(), projectID); err != nil {
return store.ErrNotFound
}
if _, err := p.st.GetAccountByOwner(member); err != nil {
return store.ErrNotFound
}
explain, err := p.explain(ctx, h)
if err != nil {
return err
}
if !iamLinkedAllowed(explain.Linked, "project.members.manage") || !iamContains(p.linked[projectID], "project.members.manage") {
return extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}
}
if p.members[projectID][member] {
return store.ErrConflict
}
p.members[projectID][member] = true
return nil
}
func iamLinkedAllowed(actions []extension.CapabilityActionExplain, actionID string) bool {
for _, action := range actions {
if action.ActionID == actionID {
return action.Allowed
}
}
return false
}
func iamContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
type iamFixture struct {
srv *Server
st *store.Store
h http.Handler
admin string
provider *iamProjectProvider
}
func newIAMFixture(t *testing.T) *iamFixture {
t.Helper()
srv, st := newAuthTestServer(t)
h := srv.Handler()
f := &iamFixture{srv: srv, st: st, h: h, admin: bootAdmin(t, h, st), provider: &iamProjectProvider{
st: st, members: map[string]map[string]bool{}, linked: map[string][]string{}, archived: map[string]bool{},
}}
srv.SetProjectLifecycle(f.provider.lifecycle(), func(owner string) extension.Host {
return iamCapabilityHost{srv: srv, owner: owner}
})
srv.Bus = cmdbus.New(st, nil, time.Now)
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: iamMemberAddCommand, Title: "添加项目成员"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
projectID, _ := req.Params["projectId"].(string)
member, _ := req.Params["member"].(string)
err := f.provider.addMember(ctx, srv.lifecycleHost(req.Owner), projectID, member)
if failure, ok := categorizeWorkFailure(extension.WorkOperationMemberAdd, err); ok {
// 假 provider 的每条失败路径都发生在改成员之前(唯一写点在最后一行
// 且随后返回 nil),因此显式声明 not-executed:拒绝语义与 4xx 映射
// 由此成立,而不是靠“报了错就算没执行”(F10)。
return nil, cmdbus.NotExecuted(failure)
}
if err != nil {
return nil, cmdbus.NotExecuted(err)
}
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
return f
}
func (f *iamFixture) explainHost(owner string) extension.CapabilityExplainProvider {
return f.srv.lifecycleHost(owner).(extension.CapabilityExplainProvider)
}
func (f *iamFixture) createProject(t *testing.T, key, name string) core.Project {
t.Helper()
w := lifecycleRequest(t, f.srv, key, http.MethodPost, "/api/projects", `{"name":"`+name+`"}`)
var project core.Project
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &project) != nil || project.ID == "" {
t.Fatalf("create %s: %d %s", name, w.Code, w.Body.String())
}
return project
}
func (f *iamFixture) addMember(t *testing.T, key, projectID, member string) (int, string, string) {
t.Helper()
w := lifecycleRequest(t, f.srv, key, http.MethodPost, "/api/commands/"+iamMemberAddCommand,
`{"params":{"projectId":"`+projectID+`","member":"`+member+`"}}`)
var out struct {
Receipt struct {
State string `json:"state"`
Reason string `json:"reason"`
} `json:"receipt"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("member add body: %d %s", w.Code, w.Body.String())
}
return w.Code, out.Receipt.State, out.Receipt.Reason
}
func (f *iamFixture) mustAddMember(t *testing.T, key, projectID, member string) {
t.Helper()
if code, state, reason := f.addMember(t, key, projectID, member); code != http.StatusOK || state != string(agentcommand.StateCommitted) {
t.Fatalf("add %s: %d %s %s", member, code, state, reason)
}
}
func (f *iamFixture) listProjects(t *testing.T, key string) []projectView {
t.Helper()
w := lifecycleRequest(t, f.srv, key, http.MethodGet, "/api/projects", "")
var out []projectView
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &out) != nil {
t.Fatalf("list: %d %s", w.Code, w.Body.String())
}
return out
}
func iamProjectNames(projects []projectView) string {
names := make([]string, 0, len(projects))
for _, project := range projects {
label := project.Name
if project.Shared {
label += "(shared)"
}
names = append(names, label)
}
return strings.Join(names, ",")
}
func TestProjectIAMCapabilityAssociationChain(t *testing.T) {
f := newIAMFixture(t)
_, key := explainRoleFixture(t, f.h, f.st, f.admin, "creator", "项目创建者", iamCreatorPolicy)
plain, _ := createLegacyAPIKeyFixture(t, f.st, "plain", "test")
createLegacyAPIKeyFixture(t, f.st, "member", "test")
me := lifecycleRequest(t, f.srv, key, http.MethodGet, "/api/me", "")
var body struct {
Linked struct {
ProjectCreate bool `json:"projectCreate"`
Source string `json:"source"`
LinkedActions []string `json:"linkedActions"`
} `json:"linkedCapabilities"`
}
if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil || !body.Linked.ProjectCreate ||
body.Linked.Source != "role" || !reflect.DeepEqual(body.Linked.LinkedActions, iamLinkedActions) {
t.Fatalf("me linked: %d %s", me.Code, me.Body.String())
}
project := f.createProject(t, key, "联动项目")
if project.Owner != "creator" || !reflect.DeepEqual(f.provider.linked[project.ID], iamLinkedActions) || !f.provider.members[project.ID]["creator"] {
t.Fatalf("creation projection: owner=%s linked=%v members=%v", project.Owner, f.provider.linked[project.ID], f.provider.members[project.ID])
}
if w := lifecycleRequest(t, f.srv, key, http.MethodPatch, "/api/projects/"+project.ID, `{"settings":{"custom.caption":"linked"}}`); w.Code != http.StatusOK ||
!strings.Contains(w.Body.String(), `"custom.caption":"linked"`) {
t.Fatalf("project.settings on own project: %d %s", w.Code, w.Body.String())
}
f.mustAddMember(t, key, project.ID, "member")
before := len(f.provider.linked)
if w := lifecycleRequest(t, f.srv, plain, http.MethodPost, "/api/projects", `{"name":"未授权"}`); w.Code != http.StatusForbidden ||
!strings.HasPrefix(w.Body.String(), "system action denied") {
t.Fatalf("create without project.create: %d %s", w.Code, w.Body.String())
}
if len(f.provider.linked) != before {
t.Fatal("refused creation reached the provider projection")
}
}
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", "协作成员", iamMemberPolicy)
carol, _ := createLegacyAPIKeyFixture(t, f.st, "carol", "test")
project := f.createProject(t, alice, "成员项目")
conversations := "/api/projects/" + project.ID + "/conversations"
f.srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
f.srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
for _, key := range []string{bob, carol} {
if w := lifecycleRequest(t, f.srv, key, http.MethodPost, conversations, `{"title":"read only"}`); w.Code != http.StatusNotFound {
t.Fatalf("document read granted project work: %d %s", w.Code, w.Body.String())
}
}
f.mustAddMember(t, alice, project.ID, "bob")
f.srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return false, nil })
f.srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return false, nil })
f.provider.workActors = nil
w := lifecycleRequest(t, f.srv, bob, http.MethodPost, conversations, `{"title":"Bob private"}`)
var created core.Conversation
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &created) != nil || created.Owner != "bob" {
t.Fatalf("member work after add: %d %s", w.Code, w.Body.String())
}
w = lifecycleRequest(t, f.srv, bob, http.MethodGet, conversations, "")
var listed []core.Conversation
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &listed) != nil || len(listed) != 1 || listed[0].ID != created.ID {
t.Fatalf("member list: %d %s", w.Code, w.Body.String())
}
if !iamContains(f.provider.workActors, "bob") {
t.Fatalf("membership did not come from ProjectLifecycle.Work: %v", f.provider.workActors)
}
if names := iamProjectNames(f.listProjects(t, bob)); names != "成员项目(shared)" {
t.Fatalf("member list projects=%s", names)
}
if w := lifecycleRequest(t, f.srv, carol, http.MethodPost, conversations, `{}`); w.Code != http.StatusNotFound {
t.Fatalf("non-member work: %d %s", w.Code, w.Body.String())
}
}
func TestProjectIAMSubjectScopingOnMemberAdd(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", "项目创建者乙", iamCreatorPolicy)
carol, _ := createLegacyAPIKeyFixture(t, f.st, "carol", "test")
dave, _ := createLegacyAPIKeyFixture(t, f.st, "dave", "test")
createLegacyAPIKeyFixture(t, f.st, "erin", "test")
bobProject := f.createProject(t, bob, "乙的项目")
notFound := "rejected: " + store.ErrNotFound.Error()
if code, state, reason := f.addMember(t, alice, bobProject.ID, "carol"); code != http.StatusBadRequest ||
state != string(agentcommand.StateRejected) || reason != notFound {
t.Fatalf("non-creator add: %d %s %q", code, state, reason)
}
if f.provider.members[bobProject.ID]["carol"] {
t.Fatal("non-creator add changed membership")
}
f.mustAddMember(t, bob, bobProject.ID, "carol")
f.mustAddMember(t, bob, bobProject.ID, "alice")
for _, key := range []string{carol, alice} {
if _, _, reason := f.addMember(t, key, bobProject.ID, "erin"); reason != notFound {
t.Fatalf("member re-delegated member add: %q", reason)
}
}
if f.provider.members[bobProject.ID]["erin"] {
t.Fatal("member add escaped the creator scope")
}
legacy, err := f.st.CreateProject("dave", "遗留项目")
if err != nil {
t.Fatal(err)
}
f.provider.members[legacy.ID] = map[string]bool{"dave": true}
if _, _, reason := f.addMember(t, dave, legacy.ID, "carol"); reason != "rejected: project.member.add: policy-rejected" {
t.Fatalf("owner without linked members.manage: %q", reason)
}
}
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", "知识编制者", 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)
}
if len(qualify.Duties) != 3 {
t.Fatalf("duties=%+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"}
if err := f.st.CreateArtifact(&artifact, []byte("IAM-DOC-ORIGINAL")); err != nil {
t.Fatal(err)
}
readers := map[string]bool{"carol": true}
var queries []extension.ResourceAuthzQuery
f.srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
queries = append(queries, q)
return q.ResourceType == "artifact" && readers[q.Actor], nil
})
read := func(key string) *httptest.ResponseRecorder {
return lifecycleRequest(t, f.srv, key, http.MethodGet, "/api/artifacts/"+artifact.ID+"/content", "")
}
expect := func(label string, want map[string]int) {
t.Helper()
before := len(f.provider.workActors)
for key, code := range want {
w := read(key)
if w.Code != code || (code == http.StatusOK && w.Body.String() != "IAM-DOC-ORIGINAL") {
t.Fatalf("%s: read=%d %s", label, w.Code, w.Body.String())
}
}
if len(f.provider.workActors) != before {
t.Fatalf("%s: document read consulted project membership", label)
}
}
expect("carol reader", map[string]int{alice: 200, carol: 200, bob: 404, dave: 404})
for _, q := range queries {
if q.Action != "download" || q.ResourceType != "artifact" || q.Owner != "alice" || q.ProjectID != project.ID {
t.Fatalf("document read query=%+v", q)
}
}
if w := lifecycleRequest(t, f.srv, carol, http.MethodPost, "/api/projects/"+project.ID+"/conversations", `{}`); w.Code != http.StatusNotFound {
t.Fatalf("document reader gained project work: %d", w.Code)
}
readers = map[string]bool{"bob": true}
expect("bob reader", map[string]int{bob: 200, carol: 404, dave: 404})
if !f.provider.members[project.ID]["dave"] || f.provider.members[project.ID]["carol"] {
t.Fatal("document read decisions changed membership")
}
}
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", "协作成员", iamMemberPolicy)
ctx := context.Background()
compareHTTP := func(label, key string, got extension.CapabilityExplain) {
t.Helper()
body := decodeCapabilityExplain(t, lifecycleRequest(t, f.srv, key, http.MethodGet, "/api/capability/explain", ""))
if body.Owner != got.Owner || body.UserID != got.UserID || body.RoleID != got.RoleID ||
!reflect.DeepEqual(body.Capability.ProjectCreate, got.ProjectCreate) || !reflect.DeepEqual(body.Capability.Linked, got.Linked) ||
!reflect.DeepEqual(body.Capability.Conflicts, got.Conflicts) || body.Capability.MigrationNote != got.MigrationNote {
t.Fatalf("%s host and HTTP explanations differ: host=%+v http=%+v", label, got, body)
}
}
creatorExplain, err := f.explainHost("creator").CurrentCapabilityExplain(ctx)
if err != nil {
t.Fatal(err)
}
if creatorExplain.SchemaVersion != extension.CapabilityExplainSchemaVersion || creatorExplain.Owner != "creator" || creatorExplain.UserID != creator.ID ||
!creatorExplain.RoleEnabled || !creatorExplain.ProjectCreate.Allowed || creatorExplain.ProjectCreate.Source != extension.CapabilitySourceRole ||
len(creatorExplain.Conflicts) != 0 || creatorExplain.MigrationNote != "" {
t.Fatalf("creator explain=%+v", creatorExplain)
}
linked := []string{}
for _, action := range creatorExplain.Linked {
if !action.Allowed || action.Source != extension.CapabilitySourceRole || action.LinkedVia != "project.create" ||
action.Scope != extension.CapabilityScopeOwnProjects || action.Conflict {
t.Fatalf("creator linked=%+v", action)
}
linked = append(linked, action.ActionID)
}
if !reflect.DeepEqual(linked, iamLinkedActions) {
t.Fatalf("creator linked actions=%v", linked)
}
compareHTTP("creator", creatorKey, creatorExplain)
memberExplain, err := f.explainHost("member").CurrentCapabilityExplain(ctx)
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 != "role-cap" || memberExplain.Conflicts == nil || len(memberExplain.Conflicts) != 0 ||
memberExplain.MigrationNote != "" {
t.Fatalf("member explain=%+v", memberExplain)
}
for _, action := range memberExplain.Linked {
if action.Allowed || action.Source != "" || action.Reason != "project-create-not-held" {
t.Fatalf("member linked=%+v", action)
}
}
compareHTTP("member", memberKey, memberExplain)
collaborate, err := f.explainHost("member").CurrentMemberBasicCollaborate(ctx)
if err != nil {
t.Fatal(err)
}
actions := map[string]extension.CapabilityActionExplain{}
for _, action := range collaborate.Actions {
actions[action.ActionID] = action
}
if collaborate.Owner != "member" || len(actions) != 4 {
t.Fatalf("member collaborate=%+v", collaborate.Actions)
}
// 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, 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)
}
grants, epoch, err := f.st.SystemGrantsForUser(adminAccount.ID)
if err != nil {
t.Fatal(err)
}
kept := []string{}
for _, action := range grants {
if action != "project.create" {
kept = append(kept, action)
}
}
if _, err := f.st.ReplaceSystemGrants("admin", adminAccount.ID, epoch, kept, "drop-admin-create"); err != nil {
t.Fatal(err)
}
if adminAccount, err = f.st.GetAccountByOwner("admin"); err != nil {
t.Fatal(err)
}
capped, err := f.st.CreateRole("admin", store.RoleCreate{Name: "受限管理"})
if err != nil {
t.Fatal(err)
}
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)
}
adminExplain, err := f.explainHost("admin").CurrentCapabilityExplain(ctx)
if err != nil {
t.Fatal(err)
}
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 {
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("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("bootstrap create: %d %s", w.Code, w.Body.String())
}
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.StatusOK || !f.provider.members[project.ID]["member"] {
t.Fatalf("bootstrap member add: %d %s", add.Code, add.Body.String())
}
}
func TestProjectIAMErrorCategorizationLifecycle(t *testing.T) {
f := newIAMFixture(t)
_, alice := explainRoleFixture(t, f.h, f.st, f.admin, "alice", "项目创建者", iamCreatorPolicy)
bob, _ := createLegacyAPIKeyFixture(t, f.st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, f.st, "mallory", "test")
project := f.createProject(t, alice, "分类项目")
f.mustAddMember(t, alice, project.ID, "bob")
if w := lifecycleRequest(t, f.srv, mallory, http.MethodPost, "/api/projects", `{"name":"x"}`); w.Code != http.StatusForbidden {
t.Fatalf("identity gate: %d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, f.srv, alice, http.MethodPost, "/api/projects", `{"name":"分类项目"}`); w.Code != http.StatusConflict ||
w.Body.String() != "project.create: duplicate-name\n" {
t.Fatalf("duplicate create: %d %q", w.Code, w.Body.String())
}
for _, tc := range []struct {
name string
err error
code int
body string
}{
{"duplicate", fmtWrap(leakyStatusError(409)), 409, "project.create: duplicate-name\n"},
{"identity", store.ErrAccountForbidden, 403, "project.create: policy-rejected\n"},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "project.create: policy-rejected\n"},
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "project.create: mapping-not-ready\n"},
{"unavailable", fmtWrap(leakyStatusError(503)), 503, "project.create: service-unavailable\n"},
} {
f.provider.createErr = tc.err
w := lifecycleRequest(t, f.srv, alice, http.MethodPost, "/api/projects", `{"name":"新项目"}`)
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
t.Fatalf("create %s: %d %q", tc.name, w.Code, w.Body.String())
}
}
f.provider.createErr = nil
for _, tc := range []struct {
name string
err error
code int
body string
}{
{"identity", store.ErrAccountForbidden, 403, "conversation.work: policy-rejected\n"},
{"policy", fmtWrap(leakyStatusError(403)), 403, "conversation.work: policy-rejected\n"},
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "conversation.work: mapping-not-ready\n"},
{"unavailable", context.DeadlineExceeded, 503, "conversation.work: service-unavailable\n"},
} {
f.provider.workErr = tc.err
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
if code, _, reason := f.addMember(t, alice, project.ID, "bob"); code != http.StatusBadRequest || reason != "rejected: project.member.add: duplicate-name" {
t.Fatalf("duplicate member: %d %q", code, reason)
}
for _, tc := range []struct {
name string
err error
code int
reason string
}{
{"duplicate", leakyStatusError(409), 409, extension.WorkFailureDuplicateName},
{"identity", store.ErrAccountForbidden, 403, extension.WorkFailurePolicyRejected},
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, extension.WorkFailurePolicyRejected},
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, extension.WorkFailureMappingNotReady},
{"unavailable", sql.ErrConnDone, 503, extension.WorkFailureServiceUnavailable},
} {
f.provider.memberErr = tc.err
code, state, reason := f.addMember(t, alice, project.ID, "mallory")
if code != http.StatusBadRequest || state != string(agentcommand.StateRejected) ||
reason != "rejected: "+extension.WorkOperationMemberAdd+": "+tc.reason || strings.Contains(reason, leakMarker) {
t.Fatalf("member add %s: %d %s %q", tc.name, code, state, reason)
}
w := httptest.NewRecorder()
writeWorkFailure(w, extension.WorkOperationMemberAdd, tc.err)
if w.Code != tc.code || w.Body.String() != extension.WorkOperationMemberAdd+": "+tc.reason+"\n" {
t.Fatalf("member add status %s: %d %q", tc.name, w.Code, w.Body.String())
}
}
f.provider.memberErr = nil
if f.provider.members[project.ID]["mallory"] {
t.Fatal("categorized refusal changed membership")
}
}
func TestProjectIAMUnauthorizedExplainReturnsNoLeakage(t *testing.T) {
f := newIAMFixture(t)
alice, aliceKey := explainRoleFixture(t, f.h, f.st, f.admin, "alice", "项目创建者", iamCreatorPolicy)
createLegacyAPIKeyFixture(t, f.st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, f.st, "mallory", "test")
createLegacyAPIKeyFixture(t, f.st, "carol", "test")
project := f.createProject(t, aliceKey, "alice-secret-project")
secrets := []string{"alice", alice.ID, "alice-secret-project", project.ID}
noLeak := func(label, body string) {
t.Helper()
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("%s leaked %q: %s", label, secret, body)
}
}
}
explain, err := f.explainHost("mallory").CurrentCapabilityExplain(context.Background())
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(explain)
if explain.Owner != "mallory" {
t.Fatalf("host explained another actor: %s", raw)
}
noLeak("host explain", string(raw))
carol, _ := f.st.GetAccountByOwner("carol")
if _, err := f.st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil {
t.Fatal(err)
}
var refusal string
for _, owner := range []string{"ghost", "carol", ""} {
_, err := f.explainHost(owner).CurrentCapabilityExplain(context.Background())
if !errors.Is(err, store.ErrAccountForbidden) || (refusal != "" && err.Error() != refusal) {
t.Fatalf("host explain %q: %v", owner, err)
}
refusal = err.Error()
}
refusal = ""
for _, query := range []string{"owner=alice", "owner=ghost", "userId=" + alice.ID, "userId=user_missing"} {
w := lifecycleRequest(t, f.srv, mallory, http.MethodGet, "/api/capability/explain?"+query, "")
if w.Code != http.StatusForbidden || (refusal != "" && w.Body.String() != refusal) {
t.Fatalf("explain %s: %d %q", query, w.Code, w.Body.String())
}
noLeak(query, w.Body.String())
refusal = w.Body.String()
}
refusal = ""
for _, probe := range []struct{ projectID, member string }{{project.ID, "bob"}, {project.ID, "ghost"}, {"prj_missing", "bob"}, {"prj_missing", "ghost"}} {
code, state, reason := f.addMember(t, mallory, probe.projectID, probe.member)
got := http.StatusText(code) + "|" + state + "|" + reason
if code != http.StatusBadRequest || (refusal != "" && got != refusal) {
t.Fatalf("member add probe %+v: %s", probe, got)
}
noLeak("member add probe", reason)
refusal = got
}
if f.provider.members[project.ID]["bob"] {
t.Fatal("cross-actor probe changed membership")
}
refusal = ""
for _, id := range []string{project.ID, "prj_missing"} {
w := lifecycleRequest(t, f.srv, mallory, http.MethodPost, "/api/projects/"+id+"/conversations", `{}`)
if w.Code != http.StatusNotFound || (refusal != "" && w.Body.String() != refusal) {
t.Fatalf("work probe %s: %d %q", id, w.Code, w.Body.String())
}
noLeak("work probe", w.Body.String())
refusal = w.Body.String()
}
for _, path := range []string{"/api/projects", "/api/me"} {
w := lifecycleRequest(t, f.srv, mallory, http.MethodGet, path, "")
if w.Code != http.StatusOK {
t.Fatalf("%s: %d", path, w.Code)
}
noLeak(path, w.Body.String())
}
}
func TestProjectIAMTwoAccountProjectLifecycle(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", "项目创建者乙", iamCreatorPolicy)
alpha := f.createProject(t, alice, "Alpha")
f.createProject(t, bob, "Beta")
if a, b := iamProjectNames(f.listProjects(t, alice)), iamProjectNames(f.listProjects(t, bob)); a != "Alpha" || b != "Beta" {
t.Fatalf("isolated lists: alice=%s bob=%s", a, b)
}
for _, q := range []struct{ method, path, body string }{
{http.MethodPatch, "/api/projects/" + alpha.ID, `{"name":"Hijacked"}`},
{http.MethodDelete, "/api/projects/" + alpha.ID, ""},
{http.MethodPost, "/api/projects/" + alpha.ID + "/conversations", `{}`},
} {
if w := lifecycleRequest(t, f.srv, bob, q.method, q.path, q.body); w.Code != http.StatusNotFound {
t.Fatalf("cross-account %s %s: %d %s", q.method, q.path, w.Code, w.Body.String())
}
}
f.mustAddMember(t, alice, alpha.ID, "bob")
if a, b := iamProjectNames(f.listProjects(t, alice)), iamProjectNames(f.listProjects(t, bob)); a != "Alpha" || b != "Alpha(shared),Beta" &&
b != "Beta,Alpha(shared)" {
t.Fatalf("lists after add: alice=%s bob=%s", a, b)
}
w := lifecycleRequest(t, f.srv, bob, http.MethodPost, "/api/projects/"+alpha.ID+"/conversations", `{"title":"Bob in Alpha"}`)
var conv core.Conversation
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &conv) != nil {
t.Fatalf("member conversation: %d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, f.srv, bob, http.MethodPatch, "/api/projects/"+alpha.ID, `{"name":"Hijacked"}`); w.Code != http.StatusNotFound {
t.Fatalf("member rename: %d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, f.srv, alice, http.MethodPatch, "/api/projects/"+alpha.ID, `{"name":"Alpha 2"}`); w.Code != http.StatusOK ||
!strings.Contains(w.Body.String(), `"name":"Alpha 2"`) {
t.Fatalf("creator rename: %d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, f.srv, alice, http.MethodDelete, "/api/projects/"+alpha.ID, ""); w.Code != http.StatusOK {
t.Fatalf("creator archive: %d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, f.srv, bob, http.MethodPost, "/api/projects/"+alpha.ID+"/conversations", `{}`); w.Code != http.StatusNotFound {
t.Fatalf("archived work: %d %s", w.Code, w.Body.String())
}
if a, b := iamProjectNames(f.listProjects(t, alice)), iamProjectNames(f.listProjects(t, bob)); a != "" || b != "Beta" {
t.Fatalf("lists after archive: alice=%s bob=%s", a, b)
}
if w := lifecycleRequest(t, f.srv, bob, http.MethodGet, "/api/conversations/"+conv.ID, ""); w.Code != http.StatusOK {
t.Fatalf("archived history: %d %s", w.Code, w.Body.String())
}
}