From afb65c5678d8e17544f045ef7143866ffdc703e3 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 03:08:12 +0000 Subject: [PATCH] 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