diff --git a/internal/server/project_iam_integration_test.go b/internal/server/project_iam_integration_test.go new file mode 100644 index 0000000..d41f6ce --- /dev/null +++ b/internal/server/project_iam_integration_test.go @@ -0,0 +1,877 @@ +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}]` +) + +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 { + return nil, failure + } + if err != nil { + return nil, 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", "协作成员", `[{"actionId":"document.read","allowed":true,"capAllowed":true}]`) + 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", "知识编制者", `[ + {"actionId":"document.author","allowed":true,"capAllowed":true}, + {"actionId":"knowledge.publish","allowed":true,"capAllowed":true}]`) + 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") + + 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 !authorQualified { + t.Fatalf("dave is not a qualified author: %+v", qualify.Duties) + } + + 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", "协作成员", `[ + {"actionId":"document.read","allowed":true,"capAllowed":true}, + {"actionId":"trace.edit","allowed":false,"capAllowed":true}]`) + 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) + } + if memberExplain.Owner != "member" || memberExplain.UserID != member.ID || memberExplain.ProjectCreate.Allowed || + memberExplain.ProjectCreate.Reason != "not-granted" || memberExplain.Conflicts == nil || len(memberExplain.Conflicts) != 0 { + 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 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 trace := actions["trace.edit"]; trace.Allowed || trace.Reason != "role-default-deny" { + t.Fatalf("member trace.edit=%+v", trace) + } + + // The bootstrap administrator keeps project.create while an enabled role caps + // one linked action, which is the only state that 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) + } + if _, err := f.st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, + []store.RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil { + t.Fatal(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.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) + } + 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) + } + } + 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) + } + + 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()) + } + 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]) + } + 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()) + } +} + +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 + 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()) + } + } + } + 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()) + } +}