agent-exec transport dd2fa5d8f3774523c4c97a4afaa4360d/4fd52114d316
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -64,6 +64,7 @@ HumanDecisionVerifier.RevokeHumanDecision
|
||||
HumanDecisionVerifier.VerifyHumanDecision
|
||||
InProc
|
||||
IntentSpec
|
||||
LinkedCapabilities
|
||||
Main
|
||||
MergeHostOptions
|
||||
NewHostClient
|
||||
|
||||
Reference in New Issue
Block a user