agent-exec transport 8315c523cef26780543b9b2130f4f4a4/5c8333ec4e92
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m18s

This commit is contained in:
agent-exec-transport
2026-10-19 12:21:32 +00:00
parent afb65c5678
commit e942a35b73
13 changed files with 1357 additions and 7 deletions
+58
View File
@@ -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
+97
View File
@@ -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)
}
}
+9
View File
@@ -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() {
+75
View File
@@ -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)
}
+372
View File
@@ -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")
}
}
+7 -6
View File
@@ -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
}
}
+224
View File
@@ -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()
}
+269
View File
@@ -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)
}
}
}
+158
View File
@@ -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
}
+56
View File
@@ -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)
}
}
}
+5 -1
View File
@@ -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")
+1
View File
@@ -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
+26
View File
@@ -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