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 { 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", "协作成员", 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()) } }