From afb65c5678d8e17544f045ef7143866ffdc703e3 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 03:08:12 +0000 Subject: [PATCH 1/2] agent-exec transport dd2fa5d8f3774523c4c97a4afaa4360d/4fd52114d316 --- internal/app/hostcaps.go | 6 + internal/authz/catalog/catalog.go | 26 ++ internal/authz/catalog/catalog_test.go | 20 ++ internal/authz/service.go | 10 + internal/server/role_authorization_test.go | 54 ++++ internal/server/server.go | 11 + internal/store/role_policy.go | 345 ++++++++++++++++++++- internal/store/role_policy_test.go | 285 +++++++++++++++++ pkg/extension/extension.go | 4 +- pkg/extension/role_policy.go | 12 + pkg/extension/surface.txt | 1 + 11 files changed, 767 insertions(+), 7 deletions(-) diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index fc025b8..07d501b 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -371,6 +371,12 @@ func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePo snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled, AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}} + linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source, + Actions: linked.LinkedActions, Conflicts: linked.Conflicts} view, err := st.RoleForUser(userID) if err != nil { return extension.RolePolicySnapshot{}, err diff --git a/internal/authz/catalog/catalog.go b/internal/authz/catalog/catalog.go index 29c7271..6d7a20e 100644 --- a/internal/authz/catalog/catalog.go +++ b/internal/authz/catalog/catalog.go @@ -65,3 +65,29 @@ func Catalog() []Descriptor { copy(out, descriptors) return out } + +// ProjectCreateAction is the system action whose holder manages the projects +// they create. Enabling it links ProjectCreateLinkedActions for those projects. +const ProjectCreateAction = "project.create" + +var projectCreateLinkedActions = []string{ + "project.settings", + "project.members.manage", + "project.permissions.manage", + "document.duties.assign", +} + +// ProjectCreateLinkedActions returns the own-project management capabilities +// that a project creator receives together with project.create. +func ProjectCreateLinkedActions() []string { + return append([]string(nil), projectCreateLinkedActions...) +} + +func IsProjectCreateLinkedAction(id string) bool { + for _, linked := range projectCreateLinkedActions { + if linked == id { + return true + } + } + return false +} diff --git a/internal/authz/catalog/catalog_test.go b/internal/authz/catalog/catalog_test.go index 66565d6..85e7d47 100644 --- a/internal/authz/catalog/catalog_test.go +++ b/internal/authz/catalog/catalog_test.go @@ -16,6 +16,26 @@ func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) { } } +func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) { + linked := ProjectCreateLinkedActions() + if len(linked) != 4 { + t.Fatalf("linked=%v", linked) + } + for _, actionID := range linked { + descriptor, ok := DescriptorByID(actionID) + if !ok || descriptor.Scope != ScopeProject || descriptor.Protected || !IsProjectCreateLinkedAction(actionID) { + t.Fatalf("linked action %s descriptor=%+v ok=%v", actionID, descriptor, ok) + } + } + if IsProjectCreateLinkedAction(ProjectCreateAction) || IsProjectCreateLinkedAction("document.read") { + t.Fatal("unlinked action reported as linked") + } + linked[0] = "mutated" + if ProjectCreateLinkedActions()[0] == "mutated" { + t.Fatal("linked actions exposed mutable storage") + } +} + func TestCatalogReturnsIndependentCopies(t *testing.T) { first := Catalog() if len(first) == 0 { diff --git a/internal/authz/service.go b/internal/authz/service.go index 71f053a..5da890e 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -77,6 +77,16 @@ func (s *Service) AuthorizeSystem(actor, actionID string) Decision { return decision } +// EffectiveLinkedCapabilities returns the own-project management actions that +// the actor's project.create links. HTTP and command readers share this call +// so the link is computed once per actor from the Backend authority. +func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) { + if s == nil || s.store == nil { + return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden + } + return s.store.LinkedCapabilities(actor) +} + func (s *Service) EffectiveSystemActions(actor string) []string { actions := []string{} for _, descriptor := range catalog.Catalog() { diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 570bd3a..2ebcbb0 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -55,6 +55,60 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) { } } +func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + if err := st.CreateAccount("creator", "Creator", "", false); err != nil { + t.Fatal(err) + } + creator, _ := st.GetAccountByOwner("creator") + key, err := st.CreateAPIKey("creator", "creator-test") + if err != nil { + t.Fatal(err) + } + roleRec := do(h, "POST", "/api/admin/roles", `{"name":"项目创建者"}`, admin) + if roleRec.Code != http.StatusOK { + t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String()) + } + role := decodeRoleHTTP(t, roleRec) + conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", + `{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin) + if conflict.Code != http.StatusBadRequest { + t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String()) + } + stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", + `{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin) + if stale.Code != http.StatusConflict { + t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String()) + } + policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", + `{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin) + if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) { + t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String()) + } + assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role", + `{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin) + if assign.Code != http.StatusOK { + t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String()) + } + me := bearerRequest(h, "GET", "/api/me", "", key) + var body struct { + Linked struct { + ProjectCreate bool `json:"projectCreate"` + Source string `json:"source"` + LinkedActions []string `json:"linkedActions"` + } `json:"linkedCapabilities"` + } + if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil { + t.Fatalf("me: %d %s", me.Code, me.Body.String()) + } + if !body.Linked.ProjectCreate || body.Linked.Source != "role" || + strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" { + t.Fatalf("linked capabilities=%+v", body.Linked) + } +} + func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/server/server.go b/internal/server/server.go index 818686a..336615e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -899,6 +899,9 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { out["mustChangePassword"] = st.MustChangePassword out["passwordSet"] = st.HasPassword out["systemActions"] = s.authorizer.EffectiveSystemActions(owner) + if linked, err := s.authorizer.EffectiveLinkedCapabilities(owner); err == nil { + out["linkedCapabilities"] = linked + } } writeJSON(w, http.StatusOK, out) } @@ -2384,6 +2387,14 @@ func writeStoreErr(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if errors.Is(err, store.ErrRolePolicyVersion) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + if errors.Is(err, store.ErrRolePolicyRoleMissing) { + http.Error(w, err.Error(), http.StatusNotFound) + return + } if errors.Is(err, store.ErrInvalidRolePolicy) { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 5424a77..16ebecd 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "sort" "strings" "time" @@ -16,6 +17,10 @@ import ( var ( ErrInvalidRolePolicy = errors.New("store: invalid role policy") ErrLastPermissionManager = errors.New("store: final permission manager") + // Role permission writers reject a stale or missing role as an invalid + // policy; the wrapped conflict/not-found class keeps HTTP retry semantics. + ErrRolePolicyVersion = fmt.Errorf("%w: role version changed: %w", ErrInvalidRolePolicy, ErrConflict) + ErrRolePolicyRoleMissing = fmt.Errorf("%w: role missing: %w", ErrInvalidRolePolicy, ErrNotFound) ) type RoleCreate struct { @@ -52,6 +57,20 @@ type PolicyReceipt struct { ReceiptID string `json:"receiptId"` AuthorityEpoch int64 `json:"authorityEpoch"` RoleVersion int64 `json:"roleVersion,omitempty"` + // LinkedExpansion lists linked rows the write added for project.create. + LinkedExpansion []string `json:"linkedExpansion,omitempty"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +// CapabilityAssociation is one actor's project.create link computed from a +// single authority snapshot. HTTP and command surfaces both read it here. +// Conflicts lists linked actions an enabled role still caps off. +type CapabilityAssociation struct { + ProjectCreate bool `json:"projectCreate"` + Source string `json:"source,omitempty"` + LinkedActions []string `json:"linkedActions"` + Conflicts []string `json:"conflicts,omitempty"` + AuthorityEpoch int64 `json:"authorityEpoch"` } // RoleRecord is the stable position definition stored by Backend. @@ -359,25 +378,64 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha return err } role, err := s.roleTx(tx, roleID, true) + if errors.Is(err, ErrNotFound) { + return ErrRolePolicyRoleMissing + } if err != nil { return err } if role.RoleVersion != expected { - return ErrConflict + return ErrRolePolicyVersion } seen := map[string]bool{} + next := map[string]RolePermissionChange{} for _, change := range changes { descriptor, ok := catalog.DescriptorByID(change.ActionID) if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) { return ErrInvalidRolePolicy } seen[change.ActionID] = true + next[change.ActionID] = change } - if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=?`), roleID); err != nil { + previous, err := s.loadCapabilityAssociationTx(tx, nil, []string{roleID}) + if err != nil { + return err + } + wasCreator, isCreator := projectCreateEnabled(previous.roles[roleID]), projectCreateEnabled(next) + kept, expanded := []string{}, []string{} + rows := append([]RolePermissionChange(nil), changes...) + if wasCreator || isCreator { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if seen[actionID] { + continue + } + if _, existed := previous.roles[roleID][actionID]; existed { + kept = append(kept, actionID) + } else if isCreator { + // The cap links management to the creator's own projects only; + // the default stays off for every other member project. + rows = append(rows, RolePermissionChange{ActionID: actionID, Allowed: false, CapAllowed: true}) + expanded = append(expanded, actionID) + } + } + } + migrationNote := "" + if wasCreator && !isCreator { + migrationNote = projectCreateDisabledNote(kept) + } + deleteQuery := `DELETE FROM role_permissions WHERE role_id=?` + deleteArgs := []any{roleID} + if len(kept) > 0 { + deleteQuery += ` AND action_id NOT IN (` + strings.TrimSuffix(strings.Repeat("?,", len(kept)), ",") + `)` + for _, actionID := range kept { + deleteArgs = append(deleteArgs, actionID) + } + } + if _, err := tx.Exec(s.q(deleteQuery), deleteArgs...); err != nil { return err } now := nowISO() - for _, change := range changes { + for _, change := range rows { if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil { return err @@ -388,12 +446,16 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha return err } if n, _ := result.RowsAffected(); n != 1 { - return ErrConflict + return ErrRolePolicyVersion + } + if err := s.checkCapabilityAssociationTx(tx, nil, []string{roleID}, nil); err != nil { + return err } if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { return err } - out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1} + out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1, + LinkedExpansion: expanded, MigrationNote: migrationNote} return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out) }) return out, err @@ -436,6 +498,9 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64 if n, _ := result.RowsAffected(); n != 1 { return ErrConflict } + if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil { + return err + } if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { return err } @@ -684,6 +749,22 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a } seen[actionID] = true } + previous, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil) + if err != nil { + return err + } + migrationNote := "" + if previous.creators[userID] && !seen[catalog.ProjectCreateAction] { + kept := []string{} + if roleID, bound := previous.bindings[userID]; bound { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, exists := previous.roles[roleID][actionID]; exists { + kept = append(kept, actionID) + } + } + } + migrationNote = projectCreateDisabledNote(kept) + } if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil { return err } @@ -702,10 +783,13 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a if managers == 0 { return ErrLastPermissionManager } + if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil { + return err + } if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { return err } - out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1} + out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, MigrationNote: migrationNote} return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out) }) return out, err @@ -784,6 +868,22 @@ func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key st if roleCount != 0 || bindingCount != 0 { return RoleAuthorityImportResult{}, ErrConflict } + pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}} + boundUsers := []string{} + for _, role := range roles { + permissions := map[string]RolePermissionChange{} + for _, permission := range role.Permissions { + permissions[permission.ActionID] = permission + } + pending.roles[role.RoleID] = permissions + for _, owner := range role.Owners { + pending.bindings[accounts[owner].ID] = role.RoleID + boundUsers = append(boundUsers, accounts[owner].ID) + } + } + if err := s.checkCapabilityAssociationTx(tx, boundUsers, nil, &pending); err != nil { + return RoleAuthorityImportResult{}, err + } var epoch int64 query := `SELECT authority_epoch FROM authority_state WHERE id=1` if s.Dialect() == "postgres" { @@ -941,6 +1041,13 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo importedUsers++ } } + importedRoleIDs := make([]string, 0, len(staged.Roles)) + for _, role := range staged.Roles { + importedRoleIDs = append(importedRoleIDs, role.RoleID) + } + if err := s.checkCapabilityAssociationTx(tx, nil, importedRoleIDs, nil); err != nil { + return RoleAuthorityImportResult{}, err + } if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { return RoleAuthorityImportResult{}, err } @@ -959,3 +1066,229 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo } return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil } + +// capabilityAssociation is the slice of authority that decides whether +// project.create and its linked own-project management actions agree. +type capabilityAssociation struct { + roles map[string]map[string]RolePermissionChange // roleID -> actionID -> bits + bindings map[string]string // userID -> active roleID + creators map[string]bool // userID -> project.create system grant +} + +func projectCreateEnabled(permissions map[string]RolePermissionChange) bool { + permission, ok := permissions[catalog.ProjectCreateAction] + return ok && permission.Allowed && permission.CapAllowed +} + +// linkedActionDenied reports an explicit hard deny: the row caps the action +// off. An absent row is unconfigured and derived from project.create; a row +// with allowed=false under an open cap still serves the creator's own projects. +func linkedActionDenied(permissions map[string]RolePermissionChange, actionID string) bool { + permission, ok := permissions[actionID] + return ok && !permission.CapAllowed +} + +func deniedLinkedAction(permissions map[string]RolePermissionChange) string { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(permissions, actionID) { + return actionID + } + } + return "" +} + +func projectCreateDisabledNote(kept []string) string { + if len(kept) == 0 { + return "project.create disabled: no linked capability rows existed; nothing was deleted" + } + return "project.create disabled: kept linked capability rows " + strings.Join(kept, ",") + + "; remove them explicitly to revoke own-project management" +} + +func associationActionArgs() []any { + args := []any{catalog.ProjectCreateAction} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + args = append(args, actionID) + } + return args +} + +// loadCapabilityAssociationTx reads the users' project.create grants and active +// roles plus, for each role, its bound project.create grant holders and the +// role rows for project.create and the linked actions. +func (s *Store) loadCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string) (capabilityAssociation, error) { + state := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, + bindings: map[string]string{}, creators: map[string]bool{}} + roleIDs = append([]string(nil), roleIDs...) + for _, userID := range userIDs { + if _, loaded := state.creators[userID]; loaded { + continue + } + var allowed bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), userID, catalog.ProjectCreateAction).Scan(&allowed) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return state, err + } + state.creators[userID] = allowed + var roleID string + err = tx.QueryRow(s.q(`SELECT role_id FROM user_role_bindings WHERE user_id=? AND active=?`), userID, true).Scan(&roleID) + switch { + case err == nil: + state.bindings[userID] = roleID + roleIDs = append(roleIDs, roleID) + case !errors.Is(err, sql.ErrNoRows): + return state, err + } + } + actionArgs := associationActionArgs() + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(actionArgs)), ",") + for _, roleID := range roleIDs { + if _, loaded := state.roles[roleID]; loaded { + continue + } + permissions := map[string]RolePermissionChange{} + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id IN (`+placeholders+`)`), + append([]any{roleID}, actionArgs...)...) + if err != nil { + return state, err + } + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + rows.Close() + return state, err + } + permissions[permission.ActionID] = permission + } + if err := rows.Close(); err != nil { + return state, err + } + state.roles[roleID] = permissions + holders, err := tx.Query(s.q(`SELECT b.user_id FROM user_role_bindings b + JOIN system_grants g ON g.user_id=b.user_id AND g.action_id=? AND g.allowed=? + WHERE b.role_id=? AND b.active=?`), catalog.ProjectCreateAction, true, roleID, true) + if err != nil { + return state, err + } + for holders.Next() { + var userID string + if err := holders.Scan(&userID); err != nil { + holders.Close() + return state, err + } + state.creators[userID] = true + state.bindings[userID] = roleID + } + if err := holders.Close(); err != nil { + return state, err + } + } + return state, nil +} + +// checkCapabilityAssociationTx is the single in-transaction gate shared by all +// role authority writers. It rejects any role that enables project.create while +// denying a linked action, and any project.create grant holder whose active +// role denies one. pending overlays rows staged but not yet written. +func (s *Store) checkCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string, pending *capabilityAssociation) error { + state, err := s.loadCapabilityAssociationTx(tx, userIDs, roleIDs) + if err != nil { + return err + } + if pending != nil { + for roleID, permissions := range pending.roles { + state.roles[roleID] = permissions + } + for userID, roleID := range pending.bindings { + state.bindings[userID] = roleID + } + for userID, allowed := range pending.creators { + state.creators[userID] = allowed + } + } + roleIDs = roleIDs[:0:0] + for roleID := range state.roles { + roleIDs = append(roleIDs, roleID) + } + sort.Strings(roleIDs) + for _, roleID := range roleIDs { + permissions := state.roles[roleID] + if denied := deniedLinkedAction(permissions); denied != "" && projectCreateEnabled(permissions) { + return fmt.Errorf("%w: role %s enables project.create but denies %s", ErrInvalidRolePolicy, roleID, denied) + } + } + userIDs = userIDs[:0:0] + for userID, creator := range state.creators { + if creator { + userIDs = append(userIDs, userID) + } + } + sort.Strings(userIDs) + for _, userID := range userIDs { + roleID, bound := state.bindings[userID] + if !bound { + continue + } + if denied := deniedLinkedAction(state.roles[roleID]); denied != "" { + return fmt.Errorf("%w: project.create grant holder %s has role %s denying %s", ErrInvalidRolePolicy, userID, roleID, denied) + } + } + return nil +} + +// LinkedCapabilities computes, from one authority snapshot, whether the actor +// holds project.create and which own-project management actions it links. +// An explicit deny on the actor's enabled role still wins over the link. +func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) { + out := CapabilityAssociation{LinkedActions: []string{}} + 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 userID string + var enabled, isAdmin bool + err = tx.QueryRow(s.q(`SELECT id,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&userID, &enabled, &isAdmin) + if errors.Is(err, sql.ErrNoRows) { + return out, ErrNotFound + } + if err != nil || !enabled { + return out, err + } + state, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil) + if err != nil { + return out, err + } + var rolePermissions map[string]RolePermissionChange + if roleID, bound := state.bindings[userID]; bound { + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return out, err + } + if role.Enabled { + rolePermissions = state.roles[roleID] + } + } + switch { + case isAdmin: + out.Source = "bootstrap-admin" + case state.creators[userID]: + out.Source = "system-grant" + case projectCreateEnabled(rolePermissions): + out.Source = "role" + default: + return out, tx.Commit() + } + out.ProjectCreate = true + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(rolePermissions, actionID) { + out.Conflicts = append(out.Conflicts, actionID) + continue + } + out.LinkedActions = append(out.LinkedActions, actionID) + } + return out, tx.Commit() +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index 99665f4..d30c35f 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -207,6 +207,291 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) { } } +func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord { + t.Helper() + permissions, err := st.RolePermissions(roleID) + if err != nil { + t.Fatal(err) + } + out := map[string]RolePermissionRecord{} + for _, permission := range permissions { + out[permission.ActionID] = permission + } + return out +} + +var linkedManagementActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"} + +func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(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: "project.create", Allowed: true, CapAllowed: true}}, "create-only") + if err != nil { + t.Fatal(err) + } + if strings.Join(receipt.LinkedExpansion, ",") != strings.Join(linkedManagementActions, ",") { + t.Fatalf("expansion=%v", receipt.LinkedExpansion) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + for _, actionID := range linkedManagementActions { + permission, ok := permissions[actionID] + // The cap opens the creator's own projects; the default stays off. + if !ok || permission.Allowed || !permission.CapAllowed { + t.Fatalf("%s=%+v ok=%v", actionID, permission, ok) + } + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("alice") + if err != nil { + t.Fatal(err) + } + if !linked.ProjectCreate || linked.Source != "role" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") || len(linked.Conflicts) != 0 { + t.Fatalf("linked=%+v", linked) + } + if replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 4 { + t.Fatalf("replay=%+v err=%v", replayed, err) + } +} + +func TestCapabilityAssociationRejectsExplicitConflictAtomically(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "受限创建者"}) + if err != nil { + t.Fatal(err) + } + epoch, _ := st.AuthorityEpoch() + conflict := []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.settings", Allowed: false, CapAllowed: false}, + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("explicit role conflict: %v", err) + } + after, _ := st.GetRole(role.RoleID) + afterEpoch, _ := st.AuthorityEpoch() + if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch { + t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch) + } + // A cap already closed on the role still blocks a later project.create. + capped := []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}} + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, capped, "platform-cap") + if err != nil { + t.Fatal(err) + } + enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, enable, "enable-over-cap"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("existing platform cap conflict: %v", err) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + if _, ok := permissions["project.create"]; ok || len(permissions) != 1 || permissions["project.members.manage"].CapAllowed { + t.Fatalf("permissions after rejected enable=%+v", permissions) + } + resolved := append(enable, RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true}) + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); err != nil { + t.Fatalf("explicit resolution: %v", err) + } +} + +func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "版本校验"}) + if err != nil { + t.Fatal(err) + } + enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + _, err = st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, enable, "stale-version") + if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) { + t.Fatalf("stale version: %v", err) + } + _, err = st.ReplaceRolePermissions("admin", "role_missing", 1, enable, "missing-role") + if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrNotFound) { + t.Fatalf("missing role: %v", err) + } + if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 { + t.Fatal("rejected write left role permissions") + } +} + +func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 { + t.Fatalf("ungranted linked=%+v err=%v", linked, err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob-create"); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("bob") + if err != nil { + t.Fatal(err) + } + if !linked.ProjectCreate || linked.Source != "system-grant" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") { + t.Fatalf("direct grant linked=%+v", linked) + } + if admin, err := st.LinkedCapabilities("admin"); err != nil || admin.Source != "bootstrap-admin" || len(admin.LinkedActions) != 4 { + t.Fatalf("admin linked=%+v err=%v", admin, err) + } + + capped, err := st.CreateRole("admin", RoleCreate{Name: "禁止管理"}) + if err != nil { + t.Fatal(err) + } + cappedReceipt, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, + []RolePermissionChange{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: false}}, "capped-role") + if err != nil { + t.Fatal(err) + } + bob, _ = st.GetAccount(bob.ID) + if _, err := st.AssignUserRole("admin", bob.ID, capped.RoleID, bob.StateVersion, "assign-capped-to-creator"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("capped role bound to project.create grant holder: %v", err) + } + if _, err := st.AssignUserRole("admin", alice.ID, capped.RoleID, alice.StateVersion, "assign-capped"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-capped-create"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("system grant over role cap: %v", err) + } + if grants, _, err := st.SystemGrantsForUser(alice.ID); err != nil || len(grants) != 0 { + t.Fatalf("rejected grant persisted=%v err=%v", grants, err) + } + // A role writer cannot later close a linked cap under a grant holder either. + open, err := st.CreateRole("admin", RoleCreate{Name: "普通职位"}) + if err != nil { + t.Fatal(err) + } + bob, _ = st.GetAccount(bob.ID) + if _, err := st.AssignUserRole("admin", bob.ID, open.RoleID, bob.StateVersion, "assign-open"); err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion, + []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("role cap under grant holder: %v", err) + } + if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 { + t.Fatalf("rejected role cap persisted: %+v", permissions) + } + if role, _ := st.GetRole(capped.RoleID); role.RoleVersion != cappedReceipt.RoleVersion { + t.Fatalf("capped role changed: %+v", role) + } +} + +func TestCapabilityAssociationDisablingProjectCreateKeepsLinkedRows(t *testing.T) { + st := openTestStore(t) + _, alice, bob := 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: "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, role.RoleID, alice.StateVersion, "assign-alice"); err != nil { + t.Fatal(err) + } + disabled, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, + []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create") + if err != nil { + t.Fatal(err) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + if _, ok := permissions["project.create"]; ok { + t.Fatalf("project.create still present: %+v", permissions) + } + if settings := permissions["project.settings"]; !settings.Allowed || !settings.CapAllowed { + t.Fatalf("project.settings not kept: %+v", permissions) + } + for _, actionID := range linkedManagementActions { + if _, ok := permissions[actionID]; !ok { + t.Fatalf("%s deleted by disabling project.create: %+v", actionID, permissions) + } + } + if !strings.Contains(disabled.MigrationNote, "project.create disabled") || !strings.Contains(disabled.MigrationNote, "project.settings") { + t.Fatalf("migration note=%q", disabled.MigrationNote) + } + if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate { + t.Fatalf("disabled create still linked=%+v err=%v", linked, err) + } + // Removal after project.create is closed is an explicit second write. + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, disabled.RoleVersion, nil, "explicit-revoke"); err != nil { + t.Fatal(err) + } + if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 { + t.Fatalf("explicit revoke kept=%+v", permissions) + } + + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + revoked, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, nil, "revoke-bob") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(revoked.MigrationNote, "project.create disabled") { + t.Fatalf("system grant migration note=%q", revoked.MigrationNote) + } +} + +func TestCapabilityAssociationImportRejectsLinkedConflict(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + bad := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("b", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2, + Permissions: []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.permissions.manage", Allowed: false, CapAllowed: false}, + }, + }}} + if _, err := st.ImportRoleAuthority("admin", bad, "import-conflict"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("conflicting import staged: %v", err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-alice"); err != nil { + t.Fatal(err) + } + cappedForHolder := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("c", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_viewer", Name: "旧查看者", Enabled: true, RoleVersion: 1, + Permissions: []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, + Owners: []string{"alice"}, + }}} + if _, err := st.ImportRoleAuthority("admin", cappedForHolder, "import-capped-holder"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("import capped grant holder: %v", err) + } + good := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("d", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2, + Permissions: []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, + Owners: []string{"bob"}, + }}} + result, err := st.ImportRoleAuthority("admin", good, "import-good") + if err != nil { + t.Fatalf("valid import after rejected attempts: %v", err) + } + if _, err := st.ActivateRoleAuthorityImport("admin", good.SourceSHA256, result.AuthorityEpoch); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("bob") + if err != nil || linked.Source != "role" || len(linked.LinkedActions) != 4 { + t.Fatalf("imported creator linked=%+v err=%v", linked, err) + } +} + func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) { st := openTestStore(t) for _, row := range []struct { diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index cc12fe4..d3c929d 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -65,7 +65,9 @@ import ( // SystemActionProvider for the Backend-owned single-effective-role policy; // resource queries carry the current authority epoch so downstream caches can // reject stale role snapshots. -const SurfaceVersion = "2.25.0-experimental" +// 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" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index 203dbe3..aec8f93 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -24,6 +24,18 @@ type RolePolicySnapshot struct { RoleVersion int64 `json:"roleVersion,omitempty"` AuthorityEpoch int64 `json:"authorityEpoch"` Permissions map[string]RolePermission `json:"permissions"` + Linked LinkedCapabilities `json:"linked"` +} + +// LinkedCapabilities is the Backend-computed project.create link for one +// account. Actions apply only to projects the account created; Source names +// where project.create comes from (bootstrap-admin, system-grant or role) and +// Conflicts lists linked actions that the account's enabled role caps off. +type LinkedCapabilities struct { + ProjectCreate bool `json:"projectCreate"` + Source string `json:"source,omitempty"` + Actions []string `json:"actions"` + Conflicts []string `json:"conflicts,omitempty"` } type RoleActionDecision struct { diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index d6f9cfb..86e7610 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -64,6 +64,7 @@ HumanDecisionVerifier.RevokeHumanDecision HumanDecisionVerifier.VerifyHumanDecision InProc IntentSpec +LinkedCapabilities Main MergeHostOptions NewHostClient From e942a35b73efd20452329f9134f082a2a7ba5312 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 12:21:32 +0000 Subject: [PATCH 2/2] 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