From e942a35b73efd20452329f9134f082a2a7ba5312 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 12:21:32 +0000 Subject: [PATCH] agent-exec transport 8315c523cef26780543b9b2130f4f4a4/5c8333ec4e92 --- internal/app/hostcaps.go | 58 ++++ internal/app/hostcaps_test.go | 97 ++++++ internal/authz/service.go | 9 + internal/server/explain.go | 75 +++++ internal/server/explain_test.go | 372 +++++++++++++++++++++++ internal/server/server.go | 13 +- internal/store/explain.go | 224 ++++++++++++++ internal/store/explain_test.go | 269 ++++++++++++++++ pkg/extension/capability_explain.go | 158 ++++++++++ pkg/extension/capability_explain_test.go | 56 ++++ pkg/extension/extension.go | 6 +- pkg/extension/host.go | 1 + pkg/extension/surface.txt | 26 ++ 13 files changed, 1357 insertions(+), 7 deletions(-) create mode 100644 internal/app/hostcaps_test.go create mode 100644 internal/server/explain.go create mode 100644 internal/server/explain_test.go create mode 100644 internal/store/explain.go create mode 100644 internal/store/explain_test.go create mode 100644 pkg/extension/capability_explain.go create mode 100644 pkg/extension/capability_explain_test.go diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 07d501b..94bdd07 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -462,6 +462,64 @@ func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID strin AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil } +func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) { + if err := ctx.Err(); err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor) + if err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion, + UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch, + RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled} + return explanation, subject, nil +} + +func capabilityActionExplains(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 inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) { + explanation, subject, err := h.currentCapabilityExplanation(ctx) + if err != nil { + return extension.CapabilityExplain{}, err + } + capability := explanation.Capability + return extension.CapabilityExplain{CapabilitySubject: subject, + ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate), + Linked: capabilityActionExplains(capability.Linked), + Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil +} + +func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) { + explanation, subject, err := h.currentCapabilityExplanation(ctx) + if err != nil { + return extension.MemberBasicCollaborate{}, err + } + return extension.MemberBasicCollaborate{CapabilitySubject: subject, + Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil +} + +func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) { + explanation, subject, err := h.currentCapabilityExplanation(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 +} + func (h inprocHost) requireRoleAdminAction(actionID string) error { if h.actor == "" || h.owner != h.actor { return store.ErrAccountForbidden diff --git a/internal/app/hostcaps_test.go b/internal/app/hostcaps_test.go new file mode 100644 index 0000000..ee94b3f --- /dev/null +++ b/internal/app/hostcaps_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []store.RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "document.read", Allowed: true, CapAllowed: true}, + {ActionID: "document.review", Allowed: false, CapAllowed: true}, + }, "explain-provider"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"} + provider, ok := any(h).(extension.CapabilityExplainProvider) + if !ok { + t.Fatal("dispatch host missing CapabilityExplainProvider") + } + ctx := context.Background() + capability, err := provider.CurrentCapabilityExplain(ctx) + if err != nil { + t.Fatal(err) + } + if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" || + capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole || + len(capability.Linked) != 4 || len(capability.Conflicts) != 0 { + t.Fatalf("capability=%+v", capability) + } + snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx) + if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) { + t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err) + } + member, err := provider.CurrentMemberBasicCollaborate(ctx) + if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 { + t.Fatalf("member=%+v err=%v", member, err) + } + for _, action := range member.Actions { + if action.ActionID == "document.read" && (!action.Allowed || action.Source != extension.CapabilitySourceRole) { + t.Fatalf("document.read=%+v", action) + } + } + document, err := provider.CurrentDocumentQualify(ctx) + if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 { + t.Fatalf("document=%+v err=%v", document, err) + } + for _, duty := range document.Duties { + if duty.ActionID == "document.review" && (!duty.Qualified || duty.DefaultAllowed) { + t.Fatalf("document.review=%+v", duty) + } + } + + // A scoped Host without a dispatch actor cannot explain anyone. + scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider) + if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped capability: %v", err) + } + if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped member: %v", err) + } + if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped document: %v", err) + } + ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider) + if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("unknown actor: %v", err) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled: %v", err) + } +} diff --git a/internal/authz/service.go b/internal/authz/service.go index 5da890e..86cb0ee 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -87,6 +87,15 @@ func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAss return s.store.LinkedCapabilities(actor) } +// ExplainCapabilities explains only the actor's own creator, member and +// document-duty capabilities; HTTP and Host providers share this call. +func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) { + if s == nil || s.store == nil { + return store.CapabilityExplanation{}, store.ErrAccountForbidden + } + return s.store.ExplainCapabilities(actor) +} + func (s *Service) EffectiveSystemActions(actor string) []string { actions := []string{} for _, descriptor := range catalog.Catalog() { diff --git a/internal/server/explain.go b/internal/server/explain.go new file mode 100644 index 0000000..9bece85 --- /dev/null +++ b/internal/server/explain.go @@ -0,0 +1,75 @@ +package server + +import ( + "database/sql" + "database/sql/driver" + "errors" + "net/http" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +// explainTargetParams would select another actor; the endpoint refuses them +// outright so the response never depends on whether that actor exists. +var explainTargetParams = []string{"owner", "userId", "user", "actor"} + +func (s *Server) explainCapability(w http.ResponseWriter, r *http.Request) { + if SessionScope(r) == store.SessionScopeSetPasswordOnly { + http.Error(w, "capability explain denied", http.StatusForbidden) + return + } + query := r.URL.Query() + for _, key := range explainTargetParams { + if query.Has(key) { + http.Error(w, "capability explain is limited to the current actor", http.StatusForbidden) + return + } + } + explanation, err := s.authorizer.ExplainCapabilities(s.owner(r)) + if errors.Is(err, store.ErrAccountForbidden) { + http.Error(w, "capability explain denied", http.StatusForbidden) + return + } + if err != nil { + http.Error(w, "capability explain unavailable", http.StatusServiceUnavailable) + return + } + writeJSON(w, http.StatusOK, explanation) +} + +// categorizeWorkFailure maps Backend and provider errors for operation into +// one WorkFailure category. Uncategorized errors, notably not-found, keep the +// existing handling so a refusal never reveals another project or account. +func categorizeWorkFailure(operation string, err error) (extension.WorkFailure, bool) { + var failure extension.WorkFailure + switch { + case err == nil: + return extension.WorkFailure{}, false + case errors.As(err, &failure) && failure.StatusCode() != 0: + return extension.CategorizeWorkFailure(operation, err) + case errors.Is(err, store.ErrAccountForbidden): + return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailurePolicyRejected}, true + case operation != extension.WorkOperationConversation && errors.Is(err, store.ErrConflict): + return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureDuplicateName}, true + case errors.Is(err, sql.ErrConnDone), errors.Is(err, driver.ErrBadConn): + return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureServiceUnavailable}, true + } + return extension.CategorizeWorkFailure(operation, err) +} + +func writeWorkFailure(w http.ResponseWriter, operation string, err error) { + if failure, ok := categorizeWorkFailure(operation, err); ok { + http.Error(w, failure.Error(), failure.StatusCode()) + return + } + writeProjectErr(w, err) +} + +func writeWorkJSON(w http.ResponseWriter, operation string, v any, err error) { + if err != nil { + writeWorkFailure(w, operation, err) + return + } + writeJSON(w, http.StatusOK, v) +} diff --git a/internal/server/explain_test.go b/internal/server/explain_test.go new file mode 100644 index 0000000..8c2d33d --- /dev/null +++ b/internal/server/explain_test.go @@ -0,0 +1,372 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// leakyStatusError carries provider detail that must never reach a client. +type leakyStatusError int + +func (e leakyStatusError) Error() string { return "secret-project of other-owner already exists" } +func (e leakyStatusError) StatusCode() int { return int(e) } + +const leakMarker = "other-owner" + +type capabilityExplainHTTP struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + RoleID string `json:"roleId"` + Capability struct { + ProjectCreate extension.CapabilityActionExplain `json:"projectCreate"` + Linked []extension.CapabilityActionExplain `json:"linked"` + Conflicts []string `json:"conflicts"` + MigrationNote string `json:"migrationNote"` + } `json:"capability"` + MemberBasicCollaborate struct { + Actions []extension.CapabilityActionExplain `json:"actions"` + } `json:"memberBasicCollaborate"` + DocumentQualify struct { + Duties []extension.DocumentDutyQualify `json:"duties"` + } `json:"documentQualify"` +} + +func explainRoleFixture(t *testing.T, h http.Handler, st *store.Store, admin, owner, name, changes string) (store.Account, string) { + t.Helper() + if err := st.CreateAccount(owner, owner, "", false); err != nil { + t.Fatal(err) + } + account, _ := st.GetAccountByOwner(owner) + key, err := st.CreateAPIKey(owner, owner+"-explain") + if err != nil { + t.Fatal(err) + } + roleRec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin) + if roleRec.Code != http.StatusOK { + t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String()) + } + role := decodeRoleHTTP(t, roleRec) + policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", + `{"expectedRoleVersion":1,"idempotencyKey":"policy-`+owner+`","changes":`+changes+`}`, admin) + if policy.Code != http.StatusOK { + t.Fatalf("policy: %d %s", policy.Code, policy.Body.String()) + } + assign := do(h, "PUT", "/api/admin/users/"+account.ID+"/role", + `{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"idempotencyKey":"assign-`+owner+`"}`, admin) + if assign.Code != http.StatusOK { + t.Fatalf("assign: %d %s", assign.Code, assign.Body.String()) + } + return account, key +} + +func decodeCapabilityExplain(t *testing.T, rec *httptest.ResponseRecorder) capabilityExplainHTTP { + t.Helper() + var out capabilityExplainHTTP + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil { + t.Fatalf("explain: %d %s", rec.Code, rec.Body.String()) + } + return out +} + +func TestCapabilityExplainHTTPReturnsCreatorSourceAndLinkedActions(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + creator, key := explainRoleFixture(t, h, st, admin, "creator", "项目创建者", + `[{"actionId":"project.create","allowed":true,"capAllowed":true}]`) + rec := bearerRequest(h, "GET", "/api/capability/explain", "", key) + body := decodeCapabilityExplain(t, rec) + if body.Owner != "creator" || body.UserID != creator.ID || body.RoleID == "" { + t.Fatalf("subject=%+v", body) + } + create := body.Capability.ProjectCreate + if !create.Allowed || create.Source != extension.CapabilitySourceRole || create.Scope != extension.CapabilityScopeSystem { + t.Fatalf("project.create=%+v", create) + } + linked := []string{} + for _, action := range body.Capability.Linked { + if !action.Allowed || action.Source != "role" || action.LinkedVia != "project.create" || action.Scope != "own-projects" { + t.Fatalf("linked=%+v", action) + } + linked = append(linked, action.ActionID) + } + if strings.Join(linked, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" || + len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" { + t.Fatalf("capability=%+v", body.Capability) + } + // The /api/me linked view and the explanation agree on one snapshot. + me := bearerRequest(h, "GET", "/api/me", "", key) + if !strings.Contains(me.Body.String(), `"source":"role"`) { + t.Fatalf("me: %s", me.Body.String()) + } + if strings.Contains(rec.Body.String(), `"admin"`) { + t.Fatalf("explanation named another account: %s", rec.Body.String()) + } +} + +func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + _, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[ + {"actionId":"document.read","allowed":true,"capAllowed":true}, + {"actionId":"trace.edit","allowed":false,"capAllowed":true}, + {"actionId":"document.author","allowed":true,"capAllowed":true}, + {"actionId":"document.approve","allowed":false,"capAllowed":false}]`) + body := decodeCapabilityExplain(t, bearerRequest(h, "GET", "/api/capability/explain", "", key)) + if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "not-granted" { + t.Fatalf("member project.create=%+v", body.Capability.ProjectCreate) + } + member := map[string]extension.CapabilityActionExplain{} + for _, action := range body.MemberBasicCollaborate.Actions { + member[action.ActionID] = action + } + if read := member["document.read"]; !read.Allowed || read.Source != "role" || read.Scope != "member-projects" { + t.Fatalf("document.read=%+v", read) + } + if trace := member["trace.edit"]; trace.Allowed || !trace.CapAllowed || trace.Reason != "role-default-deny" { + t.Fatalf("trace.edit=%+v", trace) + } + if audit := member["project.audit.read"]; audit.Allowed || audit.Reason != "action-unconfigured" { + t.Fatalf("project.audit.read=%+v", audit) + } + duties := map[string]extension.DocumentDutyQualify{} + for _, duty := range body.DocumentQualify.Duties { + duties[duty.ActionID] = duty + } + if author := duties["document.author"]; !author.Qualified || !author.DefaultAllowed || author.Source != "role" { + t.Fatalf("document.author=%+v", author) + } + if review := duties["document.review"]; review.Qualified || review.Reason != "action-unconfigured" { + t.Fatalf("document.review=%+v", review) + } + if approve := duties["document.approve"]; approve.Qualified || approve.Reason != "role-cap" { + t.Fatalf("document.approve=%+v", approve) + } +} + +func TestUnauthorizedExplainReturnsNoLeakage(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + alice, _ := explainRoleFixture(t, h, st, admin, "alice", "项目创建者", + `[{"actionId":"project.create","allowed":true,"capAllowed":true}]`) + if _, err := st.CreateProject("alice", "alice-secret-project"); err != nil { + t.Fatal(err) + } + bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "bob-explain") + if err != nil { + t.Fatal(err) + } + noLeak := func(label string, rec *httptest.ResponseRecorder) { + t.Helper() + for _, secret := range []string{"alice", alice.ID, "alice-secret-project"} { + if strings.Contains(rec.Body.String(), secret) || strings.Contains(strings.Join(rec.Header().Values("X-Owner"), ","), secret) { + t.Fatalf("%s leaked %q: %d %s", label, secret, rec.Code, rec.Body.String()) + } + } + } + // Naming another actor is refused identically whether it exists or not. + var refused string + for _, path := range []string{"/api/capability/explain?owner=alice", "/api/capability/explain?owner=nobody", + "/api/capability/explain?userId=" + alice.ID, "/api/capability/explain?userId=user_missing", + "/api/capability/explain?actor=alice", "/api/capability/explain?user=bob"} { + rec := bearerRequest(h, "GET", path, "", bobKey) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String()) + } + noLeak(path, rec) + if refused == "" { + refused = rec.Body.String() + } else if rec.Body.String() != refused { + t.Fatalf("%s distinguishable refusal %q vs %q", path, rec.Body.String(), refused) + } + } + // A set-password-only session cannot read capability explanations. + if err := st.SetUserPassword("bob", mustHash(t, "bob-temp-pass")); err != nil { + t.Fatal(err) + } + restricted, err := st.CreateSessionScoped("bob", store.SessionScopeSetPasswordOnly, time.Hour) + if err != nil { + t.Fatal(err) + } + if rec := do(h, "GET", "/api/capability/explain", "", restricted); rec.Code != http.StatusForbidden { + t.Fatalf("restricted session: %d %s", rec.Code, rec.Body.String()) + } else { + noLeak("restricted", rec) + } + // Missing, invalid and disabled credentials stop before any explanation. + if rec := do(h, "GET", "/api/capability/explain", "", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous: %d", rec.Code) + } else { + noLeak("anonymous", rec) + } + if rec := bearerRequest(h, "GET", "/api/capability/explain", "", "ak_invalid"); rec.Code != http.StatusUnauthorized { + t.Fatalf("invalid key: %d", rec.Code) + } else { + noLeak("invalid", rec) + } + bob, _ := st.GetAccountByOwner("bob") + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + if rec := bearerRequest(h, "GET", "/api/capability/explain", "", bobKey); rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden { + t.Fatalf("disabled: %d %s", rec.Code, rec.Body.String()) + } else { + noLeak("disabled", rec) + } + // Defense in depth: the handler itself refuses an unavailable actor. + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/capability/explain", nil) + srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "bob"))) + if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" { + t.Fatalf("handler disabled actor: %d %q", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "ghost"))) + if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" { + t.Fatalf("handler missing actor: %d %q", rec.Code, rec.Body.String()) + } +} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := store.HashPassword(password) + if err != nil { + t.Fatal(err) + } + return hash +} + +func TestCreateErrorCategorization(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + grantProjectCreateFixture(t, st, "alice") + mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test") + if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects", `{"name":"x"}`); w.Code != http.StatusForbidden { + t.Fatalf("unauthorized create=%d %s", w.Code, w.Body.String()) + } + cases := []struct { + name string + err error + code int + body string + }{ + {"duplicate", extension.WorkFailure{Operation: "rdm", Reason: extension.WorkFailureDuplicateName}, 409, "project.create: duplicate-name\n"}, + {"coded-duplicate", fmtWrap(leakyStatusError(409)), 409, "project.create: duplicate-name\n"}, + {"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "project.create: policy-rejected\n"}, + {"identity", store.ErrAccountForbidden, 403, "project.create: policy-rejected\n"}, + {"mapping", fmtWrap(extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}), 503, "project.create: mapping-not-ready\n"}, + {"unavailable", leakyStatusError(503), 503, "project.create: service-unavailable\n"}, + {"deadline", context.DeadlineExceeded, 503, "project.create: service-unavailable\n"}, + } + for _, tc := range cases { + err := tc.err + s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) { + return core.Project{}, err + }}, nil) + w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`) + if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) { + t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String()) + } + } + s.projectLifecycle = nil + if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"ok"}`); w.Code != http.StatusOK { + t.Fatalf("store create=%d %s", w.Code, w.Body.String()) + } +} + +func TestMemberAddErrorCategorization(t *testing.T) { + op := extension.WorkOperationMemberAdd + cases := []struct { + name string + err error + code int + body string + }{ + {"duplicate", extension.WorkFailure{Reason: extension.WorkFailureDuplicateName}, 409, "project.member.add: duplicate-name\n"}, + {"store-conflict", store.ErrConflict, 409, "project.member.add: duplicate-name\n"}, + {"coded-conflict", leakyStatusError(409), 409, "project.member.add: duplicate-name\n"}, + {"identity", store.ErrAccountForbidden, 403, "project.member.add: policy-rejected\n"}, + {"policy", fmtWrap(leakyStatusError(403)), 403, "project.member.add: policy-rejected\n"}, + {"mapping", extension.WorkFailure{Operation: op, Reason: extension.WorkFailureMappingNotReady}, 503, "project.member.add: mapping-not-ready\n"}, + {"unavailable", extension.WorkFailure{Reason: extension.WorkFailureServiceUnavailable}, 503, "project.member.add: service-unavailable\n"}, + {"conn-done", sql.ErrConnDone, 503, "project.member.add: service-unavailable\n"}, + // Unknown targets stay a plain not-found, indistinguishable from absence. + {"not-found", store.ErrNotFound, 404, "store: not found\n"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + writeWorkFailure(w, op, tc.err) + if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) { + t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String()) + } + } + if _, ok := categorizeWorkFailure(op, extension.WorkFailure{Reason: "invented"}); ok { + t.Fatal("unknown reason categorized") + } + if failure, ok := extension.CategorizeWorkFailure(op, errors.Join(errors.New("rdm"), extension.WorkFailure{Operation: "other", Reason: extension.WorkFailurePolicyRejected})); !ok || + failure.Operation != op || failure.StatusCode() != http.StatusForbidden { + t.Fatalf("provider categorization=%+v ok=%v", failure, ok) + } +} + +func TestConversationWorkErrorCategorization(t *testing.T) { + s, st := newAuthTestServer(t) + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") + p, _ := st.CreateProject("alice", "Shared") + var workErr error + member := true + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { + return member, workErr + }}, nil) + cases := []struct { + name string + err error + code int + body string + }{ + {"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "conversation.work: mapping-not-ready\n"}, + {"unavailable", fmtWrap(leakyStatusError(503)), 503, "conversation.work: service-unavailable\n"}, + {"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "conversation.work: policy-rejected\n"}, + {"identity", store.ErrAccountForbidden, 403, "conversation.work: policy-rejected\n"}, + } + for _, tc := range cases { + workErr = tc.err + for _, q := range []struct{ method, path, body string }{ + {"POST", "/api/projects/" + p.ID + "/conversations", `{"title":"t"}`}, + {"GET", "/api/projects/" + p.ID + "/conversations", ""}, + } { + w := lifecycleRequest(t, s, bob, q.method, q.path, q.body) + if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) { + t.Fatalf("%s %s %s: %d %q", tc.name, q.method, q.path, w.Code, w.Body.String()) + } + } + } + // A non-member and an unknown project are both plain not-found. + workErr, member = nil, false + for _, path := range []string{"/api/projects/" + p.ID + "/conversations", "/api/projects/prj_missing/conversations"} { + if w := lifecycleRequest(t, s, bob, "POST", path, `{}`); w.Code != http.StatusNotFound || strings.Contains(w.Body.String(), "alice") { + t.Fatalf("nonmember %s: %d %q", path, w.Code, w.Body.String()) + } + } + // Conversation work has no names, so a conflict is never a duplicate name. + if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, store.ErrConflict); ok { + t.Fatal("conversation conflict categorized as duplicate name") + } + if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, leakyStatusError(409)); ok { + t.Fatal("conversation coded conflict categorized as duplicate name") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 336615e..2343c7a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -335,6 +335,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants) mux.HandleFunc("PUT /api/admin/users/{userId}/system-grants", s.replaceSystemGrants) mux.HandleFunc("GET /api/authz/explain", s.explainSystemAuthorization) + mux.HandleFunc("GET /api/capability/explain", s.explainCapability) // ---- 项目 / 会话 ---- mux.HandleFunc("GET /api/projects", s.listProjects) @@ -754,11 +755,11 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { return } p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) - writeProjectJSON(w, s.publicProjectMetadata(p), err) + writeWorkJSON(w, extension.WorkOperationProjectCreate, s.publicProjectMetadata(p), err) return } p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled) - writeJSONOr(w, p, err) + writeWorkJSON(w, extension.WorkOperationProjectCreate, p, err) } func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { @@ -859,7 +860,7 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if s.projectLifecycle != nil { if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { - writeProjectErr(w, err) + writeWorkFailure(w, extension.WorkOperationConversation, err) return } } else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { @@ -916,7 +917,7 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { req.Title = "新会话" } c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - writeProjectJSON(w, c, err) + writeWorkJSON(w, extension.WorkOperationConversation, c, err) } // conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。 @@ -1048,7 +1049,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { return } if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { - writeProjectErr(w, err) + writeWorkFailure(w, extension.WorkOperationConversation, err) return } var req struct { @@ -1340,7 +1341,7 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } else { hostProject = string(conv.ProjectID) if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil { - writeProjectErr(w, err) + writeWorkFailure(w, extension.WorkOperationConversation, err) return } } diff --git a/internal/store/explain.go b/internal/store/explain.go new file mode 100644 index 0000000..2e32285 --- /dev/null +++ b/internal/store/explain.go @@ -0,0 +1,224 @@ +package store + +import ( + "database/sql" + "errors" + "strings" + + "agent-system-backend/internal/authz/catalog" +) + +const ( + CapabilitySourceRole = "role" + CapabilitySourceSystemGrant = "system-grant" + CapabilitySourceBootstrapAdmin = "bootstrap-admin" + + CapabilityScopeSystem = "system" + CapabilityScopeOwnProjects = "own-projects" + CapabilityScopeMemberProjects = "member-projects" +) + +// memberBasicCollaborateActions are the role defaults a member brings into a +// project; documentQualifyDuties are the three assignable document duties. +var ( + memberBasicCollaborateActions = []string{"document.read", "trace.edit", "knowledge.publish", "project.audit.read"} + documentQualifyDuties = []string{"document.author", "document.review", "document.approve"} +) + +type CapabilitySubject struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + AuthorityEpoch int64 `json:"authorityEpoch"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleEnabled bool `json:"roleEnabled"` +} + +type CapabilityActionExplain struct { + ActionID string `json:"actionId"` + Scope string `json:"scope"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + Source string `json:"source,omitempty"` + LinkedVia string `json:"linkedVia,omitempty"` + Conflict bool `json:"conflict,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type CapabilityExplain struct { + ProjectCreate CapabilityActionExplain `json:"projectCreate"` + Linked []CapabilityActionExplain `json:"linked"` + Conflicts []string `json:"conflicts"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +type MemberBasicCollaborate struct { + Actions []CapabilityActionExplain `json:"actions"` +} + +type DocumentDutyQualify struct { + ActionID string `json:"actionId"` + Qualified bool `json:"qualified"` + DefaultAllowed bool `json:"defaultAllowed"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type DocumentQualify struct { + Duties []DocumentDutyQualify `json:"duties"` +} + +// CapabilityExplanation is one actor's creator, member and document-duty +// explanation computed from a single authority snapshot. +type CapabilityExplanation struct { + CapabilitySubject + Capability CapabilityExplain `json:"capability"` + MemberBasicCollaborate MemberBasicCollaborate `json:"memberBasicCollaborate"` + DocumentQualify DocumentQualify `json:"documentQualify"` +} + +func (s *Store) rolePermissionsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) { + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]RolePermissionChange{} + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + return nil, err + } + out[permission.ActionID] = permission + } + return out, rows.Err() +} + +// roleActionExplain uses the same reason vocabulary as the Host role check. +func roleActionExplain(actionID, scope string, bound, enabled bool, permissions map[string]RolePermissionChange) CapabilityActionExplain { + out := CapabilityActionExplain{ActionID: actionID, Scope: scope} + if !bound { + out.Reason = "role-unassigned" + return out + } + if !enabled { + out.Reason = "role-disabled" + return out + } + permission, ok := permissions[actionID] + switch { + case !ok: + out.Reason = "action-unconfigured" + case !permission.CapAllowed: + out.Reason = "role-cap" + case !permission.Allowed: + out.CapAllowed, out.Reason = true, "role-default-deny" + default: + out.Allowed, out.CapAllowed, out.Source = true, true, CapabilitySourceRole + } + return out +} + +func capabilityExplainNote(projectCreate bool, conflicts, kept []string) string { + if projectCreate && len(conflicts) > 0 { + return "project.create linked actions capped by the enabled role: " + strings.Join(conflicts, ",") + + "; the cap wins on created projects until the role row is changed explicitly" + } + if !projectCreate && len(kept) > 0 { + return "project.create not held: linked capability rows " + strings.Join(kept, ",") + + " remain on the role; remove them explicitly to revoke own-project management" + } + return "" +} + +// ExplainCapabilities explains the owner's own capabilities. A missing or +// disabled account is ErrAccountForbidden so callers cannot probe presence. +func (s *Store) ExplainCapabilities(owner string) (CapabilityExplanation, error) { + out := CapabilityExplanation{} + if owner == "" { + return out, ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return out, err + } + defer tx.Rollback() + if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil { + return out, err + } + var enabled, isAdmin bool + err = tx.QueryRow(s.q(`SELECT id,owner,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&out.UserID, &out.Owner, &enabled, &isAdmin) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) { + return CapabilityExplanation{}, ErrAccountForbidden + } + if err != nil { + return CapabilityExplanation{}, err + } + state, err := s.loadCapabilityAssociationTx(tx, []string{out.UserID}, nil) + if err != nil { + return CapabilityExplanation{}, err + } + roleID, bound := state.bindings[out.UserID] + permissions := map[string]RolePermissionChange{} + if bound { + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return CapabilityExplanation{}, err + } + out.RoleID, out.RoleName, out.RoleEnabled = role.RoleID, role.Name, role.Enabled + if permissions, err = s.rolePermissionsTx(tx, roleID); err != nil { + return CapabilityExplanation{}, err + } + } + var enabledPermissions map[string]RolePermissionChange + if out.RoleEnabled { + enabledPermissions = permissions + } + + create := roleActionExplain(catalog.ProjectCreateAction, CapabilityScopeSystem, bound, out.RoleEnabled, permissions) + switch { + case isAdmin: + create.Allowed, create.Source, create.Reason = true, CapabilitySourceBootstrapAdmin, "" + case state.creators[out.UserID]: + create.Allowed, create.Source, create.Reason = true, CapabilitySourceSystemGrant, "" + case create.Allowed: + case create.Reason == "role-unassigned" || create.Reason == "action-unconfigured": + create.Reason = "not-granted" + } + capability := CapabilityExplain{ProjectCreate: create, Linked: []CapabilityActionExplain{}, Conflicts: []string{}} + kept := []string{} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, exists := permissions[actionID]; exists { + kept = append(kept, actionID) + } + linked := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeOwnProjects} + switch { + case !create.Allowed: + linked.Reason = "project-create-not-held" + case linkedActionDenied(enabledPermissions, actionID): + linked.LinkedVia, linked.Conflict, linked.Reason = catalog.ProjectCreateAction, true, "role-cap" + capability.Conflicts = append(capability.Conflicts, actionID) + default: + linked.Allowed, linked.CapAllowed, linked.Source, linked.LinkedVia = true, true, create.Source, catalog.ProjectCreateAction + } + capability.Linked = append(capability.Linked, linked) + } + capability.MigrationNote = capabilityExplainNote(create.Allowed, capability.Conflicts, kept) + out.Capability = capability + + out.MemberBasicCollaborate.Actions = make([]CapabilityActionExplain, 0, len(memberBasicCollaborateActions)) + for _, actionID := range memberBasicCollaborateActions { + out.MemberBasicCollaborate.Actions = append(out.MemberBasicCollaborate.Actions, + roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions)) + } + out.DocumentQualify.Duties = make([]DocumentDutyQualify, 0, len(documentQualifyDuties)) + for _, actionID := range documentQualifyDuties { + decision := roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions) + duty := DocumentDutyQualify{ActionID: actionID, Qualified: decision.CapAllowed, DefaultAllowed: decision.Allowed, Reason: decision.Reason} + if duty.Qualified { + duty.Source = CapabilitySourceRole + } + out.DocumentQualify.Duties = append(out.DocumentQualify.Duties, duty) + } + return out, tx.Commit() +} diff --git a/internal/store/explain_test.go b/internal/store/explain_test.go new file mode 100644 index 0000000..29d406f --- /dev/null +++ b/internal/store/explain_test.go @@ -0,0 +1,269 @@ +package store + +import ( + "errors" + "strings" + "testing" +) + +func explainActionMap(actions []CapabilityActionExplain) map[string]CapabilityActionExplain { + out := map[string]CapabilityActionExplain{} + for _, action := range actions { + out[action.ActionID] = action + } + return out +} + +func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "explain-create"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-assign"); err != nil { + t.Fatal(err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "explain-grant"); err != nil { + t.Fatal(err) + } + for owner, source := range map[string]string{"alice": "role", "bob": "system-grant", "admin": "bootstrap-admin"} { + explanation, err := st.ExplainCapabilities(owner) + if err != nil { + t.Fatalf("%s: %v", owner, err) + } + linked, err := st.LinkedCapabilities(owner) + if err != nil { + t.Fatal(err) + } + capability := explanation.Capability + if explanation.Owner != owner || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != source || + capability.ProjectCreate.Source != linked.Source || capability.ProjectCreate.Scope != CapabilityScopeSystem || + len(capability.Conflicts) != 0 || capability.MigrationNote != "" || explanation.AuthorityEpoch != linked.AuthorityEpoch { + t.Fatalf("%s explanation=%+v linked=%+v", owner, explanation, linked) + } + allowed := []string{} + for _, action := range capability.Linked { + if !action.Allowed || action.Source != source || action.LinkedVia != "project.create" || action.Scope != CapabilityScopeOwnProjects { + t.Fatalf("%s linked action=%+v", owner, action) + } + allowed = append(allowed, action.ActionID) + } + if strings.Join(allowed, ",") != strings.Join(linked.LinkedActions, ",") { + t.Fatalf("%s explain linked=%v association=%v", owner, allowed, linked.LinkedActions) + } + } + alicePolicy, _ := st.ExplainCapabilities("alice") + if alicePolicy.RoleID != role.RoleID || alicePolicy.RoleName != "项目负责人" || !alicePolicy.RoleEnabled { + t.Fatalf("alice subject=%+v", alicePolicy.CapabilitySubject) + } +} + +func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) { + st := openTestStore(t) + admin, alice, _ := rolePolicyAccounts(t, st) + // Bootstrap admin keeps project.create through is_admin even without the + // grant, so a role cap on a linked action is reported as a conflict. + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, []string{"system.permissions.manage", "system.roles.manage", "system.roles.assign"}, "drop-admin-create"); err != nil { + t.Fatal(err) + } + capped, err := st.CreateRole("admin", RoleCreate{Name: "受限管理"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, + []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil { + t.Fatal(err) + } + admin, _ = st.GetAccount(admin.ID) + if _, err := st.AssignUserRole("admin", admin.ID, capped.RoleID, admin.StateVersion, "assign-admin-capped"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("admin") + if err != nil { + t.Fatal(err) + } + linked, _ := st.LinkedCapabilities("admin") + capability := explanation.Capability + if capability.ProjectCreate.Source != "bootstrap-admin" || strings.Join(capability.Conflicts, ",") != "project.members.manage" || + strings.Join(capability.Conflicts, ",") != strings.Join(linked.Conflicts, ",") { + t.Fatalf("conflicts=%+v linked=%+v", capability, linked) + } + members := explainActionMap(capability.Linked)["project.members.manage"] + if members.Allowed || !members.Conflict || members.Reason != "role-cap" || members.Source != "" { + t.Fatalf("conflicting linked action=%+v", members) + } + if !strings.Contains(capability.MigrationNote, "project.members.manage") { + t.Fatalf("conflict note=%q", capability.MigrationNote) + } + + // Disabling project.create keeps linked rows; the explanation says so. + creator, err := st.CreateRole("admin", RoleCreate{Name: "创建者"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", creator.RoleID, creator.RoleVersion, []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.settings", Allowed: true, CapAllowed: true}, + }, "enable-create") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, creator.RoleID, alice.StateVersion, "assign-alice-creator"); err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion, + []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create"); err != nil { + t.Fatal(err) + } + disabled, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + if disabled.Capability.ProjectCreate.Allowed || disabled.Capability.ProjectCreate.Reason != "not-granted" { + t.Fatalf("disabled create=%+v", disabled.Capability.ProjectCreate) + } + for _, action := range disabled.Capability.Linked { + if action.Allowed || action.Reason != "project-create-not-held" { + t.Fatalf("linked after disable=%+v", action) + } + } + if note := disabled.Capability.MigrationNote; !strings.Contains(note, "project.create not held") || !strings.Contains(note, "project.settings") { + t.Fatalf("migration note=%q", note) + } +} + +func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "协作成员"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ + {ActionID: "document.read", Allowed: true, CapAllowed: true}, + {ActionID: "trace.edit", Allowed: false, CapAllowed: true}, + {ActionID: "knowledge.publish", Allowed: false, CapAllowed: false}, + }, "member-policy"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + actions := explainActionMap(explanation.MemberBasicCollaborate.Actions) + want := map[string]CapabilityActionExplain{ + "document.read": {ActionID: "document.read", Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"}, + "trace.edit": {ActionID: "trace.edit", Scope: CapabilityScopeMemberProjects, CapAllowed: true, Reason: "role-default-deny"}, + "knowledge.publish": {ActionID: "knowledge.publish", Scope: CapabilityScopeMemberProjects, Reason: "role-cap"}, + "project.audit.read": {ActionID: "project.audit.read", Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"}, + } + if len(actions) != len(want) { + t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions) + } + for actionID, expected := range want { + if actions[actionID] != expected { + t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], expected) + } + } + unassigned, err := st.ExplainCapabilities("bob") + if err != nil { + t.Fatal(err) + } + for _, action := range unassigned.MemberBasicCollaborate.Actions { + if action.Allowed || action.Reason != "role-unassigned" { + t.Fatalf("unassigned member action=%+v", action) + } + } + // Bootstrap admin is a system source only; it grants no member content. + adminExplain, err := st.ExplainCapabilities("admin") + if err != nil { + t.Fatal(err) + } + for _, action := range adminExplain.MemberBasicCollaborate.Actions { + if action.Allowed || action.Source != "" { + t.Fatalf("admin member action=%+v", action) + } + } +} + +func TestExplainCapabilityDocumentQualify(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "文档职责"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ + {ActionID: "document.author", Allowed: true, CapAllowed: true}, + {ActionID: "document.review", Allowed: false, CapAllowed: true}, + {ActionID: "document.approve", Allowed: false, CapAllowed: false}, + }, "duty-policy") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + got := map[string]DocumentDutyQualify{} + for _, duty := range explanation.DocumentQualify.Duties { + got[duty.ActionID] = duty + } + want := map[string]DocumentDutyQualify{ + "document.author": {ActionID: "document.author", Qualified: true, DefaultAllowed: true, Source: "role"}, + "document.review": {ActionID: "document.review", Qualified: true, Source: "role", Reason: "role-default-deny"}, + "document.approve": {ActionID: "document.approve", Reason: "role-cap"}, + } + if len(got) != len(want) { + t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties) + } + for actionID, expected := range want { + if got[actionID] != expected { + t.Fatalf("%s=%+v want %+v", actionID, got[actionID], expected) + } + } + disabled := false + if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + explanation, err = st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + for _, duty := range explanation.DocumentQualify.Duties { + if duty.Qualified || duty.Reason != "role-disabled" { + t.Fatalf("disabled role duty=%+v", duty) + } + } +} + +func TestExplainCapabilityRejectsMissingAndDisabledAccountsAlike(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + if err := st.CreateAccount("carol", "Carol", "hash", false); err != nil { + t.Fatal(err) + } + carol, _ := st.GetAccountByOwner("carol") + if _, err := st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"", "nobody", "carol"} { + explanation, err := st.ExplainCapabilities(owner) + if !errors.Is(err, ErrAccountForbidden) || explanation.UserID != "" || explanation.Owner != "" { + t.Fatalf("%q explanation=%+v err=%v", owner, explanation, err) + } + } +} diff --git a/pkg/extension/capability_explain.go b/pkg/extension/capability_explain.go new file mode 100644 index 0000000..73ac903 --- /dev/null +++ b/pkg/extension/capability_explain.go @@ -0,0 +1,158 @@ +package extension + +import ( + "context" + "errors" + "net/http" +) + +const CapabilityExplainSchemaVersion = 1 + +// Capability sources name where an allowed action comes from. +const ( + CapabilitySourceRole = "role" + CapabilitySourceSystemGrant = "system-grant" + CapabilitySourceBootstrapAdmin = "bootstrap-admin" +) + +// Capability scopes name where an allowed action applies. +const ( + CapabilityScopeSystem = "system" + CapabilityScopeOwnProjects = "own-projects" + CapabilityScopeMemberProjects = "member-projects" +) + +// CapabilitySubject identifies the Host-bound actor and the authority snapshot +// an explanation was computed from. It never names another account. +type CapabilitySubject struct { + SchemaVersion int `json:"schemaVersion"` + UserID string `json:"userId"` + Owner string `json:"owner"` + AuthorityEpoch int64 `json:"authorityEpoch"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleEnabled bool `json:"roleEnabled"` +} + +// CapabilityActionExplain reports one action's effective decision. Source is +// set only when Allowed; LinkedVia names the action that links it; Conflict +// marks a linked action that the enabled role caps off. +type CapabilityActionExplain struct { + ActionID string `json:"actionId"` + Scope string `json:"scope"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + Source string `json:"source,omitempty"` + LinkedVia string `json:"linkedVia,omitempty"` + Conflict bool `json:"conflict,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// CapabilityExplain is the creator explanation: project.create, the linked +// own-project management actions, their conflicts and the migration note. +type CapabilityExplain struct { + CapabilitySubject + ProjectCreate CapabilityActionExplain `json:"projectCreate"` + Linked []CapabilityActionExplain `json:"linked"` + Conflicts []string `json:"conflicts"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +// MemberBasicCollaborate explains the role defaults a member brings to the +// projects they are a member of. Bootstrap admin is not a content source. +type MemberBasicCollaborate struct { + CapabilitySubject + Actions []CapabilityActionExplain `json:"actions"` +} + +// DocumentDutyQualify reports whether the role cap lets a document duty be +// assigned to the actor and whether the role grants it by default. +type DocumentDutyQualify struct { + ActionID string `json:"actionId"` + Qualified bool `json:"qualified"` + DefaultAllowed bool `json:"defaultAllowed"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type DocumentQualify struct { + CapabilitySubject + Duties []DocumentDutyQualify `json:"duties"` +} + +// CapabilityExplainProvider is optional on a trusted dispatch Host. Every +// method explains only the Host-bound actor; callers cannot name another one. +type CapabilityExplainProvider interface { + CurrentCapabilityExplain(context.Context) (CapabilityExplain, error) + CurrentMemberBasicCollaborate(context.Context) (MemberBasicCollaborate, error) + CurrentDocumentQualify(context.Context) (DocumentQualify, error) +} + +// Work operations whose failures are categorized for clients. +const ( + WorkOperationProjectCreate = "project.create" + WorkOperationMemberAdd = "project.member.add" + WorkOperationConversation = "conversation.work" +) + +// Work failure reasons. Each maps to exactly one HTTP status. +const ( + WorkFailureDuplicateName = "duplicate-name" + WorkFailurePolicyRejected = "policy-rejected" + WorkFailureMappingNotReady = "mapping-not-ready" + WorkFailureServiceUnavailable = "service-unavailable" +) + +// WorkFailure is a categorized refusal a provider may return from project +// create, member add or conversation work. Its text carries only the operation +// and reason, never an account, project or name. +type WorkFailure struct { + Operation string `json:"operation"` + Reason string `json:"reason"` +} + +func (f WorkFailure) Error() string { return f.Operation + ": " + f.Reason } + +// StatusCode returns 0 for an unknown reason so it is never trusted as a status. +func (f WorkFailure) StatusCode() int { + switch f.Reason { + case WorkFailureDuplicateName: + return http.StatusConflict + case WorkFailurePolicyRejected: + return http.StatusForbidden + case WorkFailureMappingNotReady, WorkFailureServiceUnavailable: + return http.StatusServiceUnavailable + } + return 0 +} + +// CategorizeWorkFailure classifies a provider error for operation. A known +// WorkFailure keeps its reason; a coded 403/503 (and 409 outside conversation +// work) maps to its category; a deadline is service-unavailable. Anything else +// is left uncategorized so the caller keeps its existing not-found handling. +func CategorizeWorkFailure(operation string, err error) (WorkFailure, bool) { + if err == nil { + return WorkFailure{}, false + } + var failure WorkFailure + if errors.As(err, &failure) && failure.StatusCode() != 0 { + return WorkFailure{Operation: operation, Reason: failure.Reason}, true + } + var coded interface{ StatusCode() int } + if errors.As(err, &coded) { + switch coded.StatusCode() { + case http.StatusForbidden: + return WorkFailure{Operation: operation, Reason: WorkFailurePolicyRejected}, true + case http.StatusConflict: + if operation != WorkOperationConversation { + return WorkFailure{Operation: operation, Reason: WorkFailureDuplicateName}, true + } + case http.StatusServiceUnavailable: + return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true + } + } + if errors.Is(err, context.DeadlineExceeded) { + return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true + } + return WorkFailure{}, false +} diff --git a/pkg/extension/capability_explain_test.go b/pkg/extension/capability_explain_test.go new file mode 100644 index 0000000..85244b1 --- /dev/null +++ b/pkg/extension/capability_explain_test.go @@ -0,0 +1,56 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" +) + +type codedTestError int + +func (e codedTestError) Error() string { return "provider detail about other-owner" } +func (e codedTestError) StatusCode() int { return int(e) } + +func TestWorkFailureStatusAndTextCarryNoIdentifiers(t *testing.T) { + for reason, code := range map[string]int{ + WorkFailureDuplicateName: http.StatusConflict, + WorkFailurePolicyRejected: http.StatusForbidden, + WorkFailureMappingNotReady: http.StatusServiceUnavailable, + WorkFailureServiceUnavailable: http.StatusServiceUnavailable, + "invented": 0, + } { + failure := WorkFailure{Operation: WorkOperationMemberAdd, Reason: reason} + if failure.StatusCode() != code || failure.Error() != WorkOperationMemberAdd+": "+reason { + t.Fatalf("%s: status=%d text=%q", reason, failure.StatusCode(), failure.Error()) + } + } +} + +func TestCategorizeWorkFailure(t *testing.T) { + cases := []struct { + op string + err error + reason string + ok bool + }{ + {WorkOperationProjectCreate, fmt.Errorf("wrap: %w", WorkFailure{Reason: WorkFailureMappingNotReady}), WorkFailureMappingNotReady, true}, + {WorkOperationProjectCreate, codedTestError(409), WorkFailureDuplicateName, true}, + {WorkOperationMemberAdd, codedTestError(403), WorkFailurePolicyRejected, true}, + {WorkOperationConversation, codedTestError(503), WorkFailureServiceUnavailable, true}, + {WorkOperationConversation, codedTestError(409), "", false}, + {WorkOperationConversation, context.DeadlineExceeded, WorkFailureServiceUnavailable, true}, + {WorkOperationMemberAdd, codedTestError(404), "", false}, + {WorkOperationMemberAdd, WorkFailure{Reason: "invented"}, "", false}, + {WorkOperationMemberAdd, errors.New("plain"), "", false}, + {WorkOperationMemberAdd, nil, "", false}, + } + for _, tc := range cases { + failure, ok := CategorizeWorkFailure(tc.op, tc.err) + if ok != tc.ok || failure.Reason != tc.reason || (ok && failure.Operation != tc.op) || strings.Contains(failure.Error(), "other-owner") { + t.Fatalf("%s %v: failure=%+v ok=%v", tc.op, tc.err, failure, ok) + } + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index d3c929d..f37baba 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -67,7 +67,11 @@ import ( // reject stale role snapshots. // 2.26.0-experimental: IAM link U1 RolePolicySnapshot.Linked carries the // Backend-computed project.create own-project management link and its source. -const SurfaceVersion = "2.26.0-experimental" +// 2.27.0-experimental: IAM link U3 optional CapabilityExplainProvider +// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the +// Host-bound actor) and categorized WorkFailure for create, member add and +// conversation work. +const SurfaceVersion = "2.27.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/host.go b/pkg/extension/host.go index cd0f414..197a428 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -49,6 +49,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error) // - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定 // - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应 // - RolePolicyProvider — Backend 权威职位与动作默认/上限 +// - CapabilityExplainProvider — 当前分发主体的创建者/成员/文档职责能力解释 type Host interface { // PluginID 返回注册处理器的插件标识(形如 rdm)。 PluginID() string diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 86e7610..ae3247c 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -26,9 +26,26 @@ AdminProvider AdminProvider.IsAdmin AdminZoneSpec CLIHook +CapabilityActionExplain +CapabilityExplain +CapabilityExplainProvider +CapabilityExplainProvider.CurrentCapabilityExplain +CapabilityExplainProvider.CurrentDocumentQualify +CapabilityExplainProvider.CurrentMemberBasicCollaborate +CapabilityExplainSchemaVersion +CapabilityScopeMemberProjects +CapabilityScopeOwnProjects +CapabilityScopeSystem +CapabilitySourceBootstrapAdmin +CapabilitySourceRole +CapabilitySourceSystemGrant +CapabilitySubject +CategorizeWorkFailure Compiled DelegatedProjectProjectionHost DelegatedProjectProjectionHost.ProjectProjectionForCreator +DocumentDutyQualify +DocumentQualify ErrNotLinked ErrProjectAuditDenied ErrProjectAuditLinkConflict @@ -66,6 +83,7 @@ InProc IntentSpec LinkedCapabilities Main +MemberBasicCollaborate MergeHostOptions NewHostClient NewScopedHost @@ -240,6 +258,11 @@ WorkCompletionHost WorkCompletionHost.CompleteForWork WorkCompletionRequest WorkCompletionResult +WorkFailure +WorkFailureDuplicateName +WorkFailureMappingNotReady +WorkFailurePolicyRejected +WorkFailureServiceUnavailable WorkObservation WorkObservationAccepted WorkObservationDenied @@ -247,5 +270,8 @@ WorkObservationPartial WorkObservationPending WorkObservationResult WorkObservationState +WorkOperationConversation +WorkOperationMemberAdd +WorkOperationProjectCreate WorkTaskHost WorkTaskHost.TaskCreateForWork