From 06ef17a70a558f431d8fc10327448bca79305cbb Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 02:27:50 +0000 Subject: [PATCH] Harden delegated role authority boundaries Why: Independent review found delegated account actions, stale resource policy, and role migration cutover gaps in the initial U4 implementation. Evidence: Backend full SQLite suite passed; isolated PostgreSQL role and migration suite passed; independent final review found no remaining findings. Impact: Enforces transactional system grants, current role snapshots, staged role import with v36 migration, and fail-closed activation. Refs: feature/iam-u4-role-authority --- cmd/storage-migrate/main.go | 2 +- internal/app/app.go | 14 +- internal/app/hostcaps.go | 111 +++++- internal/app/resource_authz_v2_test.go | 16 + internal/app/role_provider_test.go | 17 + .../server/resource_authz_consumption_test.go | 6 +- internal/server/role_authorization_test.go | 52 +++ internal/server/server.go | 12 +- internal/store/account_directory.go | 4 +- internal/store/account_model.go | 16 +- internal/store/account_model_test.go | 3 + internal/store/account_state.go | 22 +- internal/store/migrate.go | 12 + internal/store/migrate_compat.go | 1 + internal/store/role_policy.go | 326 ++++++++++++++++++ internal/store/role_policy_migration_test.go | 20 ++ internal/store/role_policy_test.go | 71 ++++ pkg/extension/extension.go | 10 +- pkg/extension/role_policy.go | 45 +++ pkg/extension/surface.txt | 10 + 20 files changed, 748 insertions(+), 22 deletions(-) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 787c57c..9deaa07 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -281,7 +281,7 @@ func cmdBackup(args []string) error { // copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。 // admission_leases 是运行态执行许可(迁移无意义),明确排除。 var copyOrder = []string{ - "projects", "users", "roles", "role_permissions", "user_role_bindings", "system_grants", "authority_state", "policy_receipts", + "projects", "users", "roles", "role_permissions", "user_role_bindings", "system_grants", "authority_state", "policy_receipts", "role_authority_import_state", "account_directory_revision", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", diff --git a/internal/app/app.go b/internal/app/app.go index 835884c..924557d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -620,7 +620,19 @@ func Run(opt Options) error { bus.SetPendingStore(st) pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 - authzSlot := &projectAuthz{} + authzSlot := &projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) { + account, err := st.GetAccountByOwner(q.Actor) + if err != nil { + return q, err + } + snapshot, err := rolePolicySnapshotForUser(st, account.ID) + if err != nil { + return q, err + } + q.AuthorityEpoch = snapshot.AuthorityEpoch + q.RolePolicy = &snapshot + return q, nil + }} projectSlot := &projectLifecycleSlot{} directorySlot, err := newAccountDirectorySlot(st) if err != nil { diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 3312b3f..bbe40c6 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -42,6 +42,7 @@ type projectAuthz struct { fn extension.ResourceAuthorizer holder2 string // EXT-8b 资源级持有者(首个注册者生效) fn2 extension.ResourceAuthorizerV2 + enrich func(extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) } func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) { @@ -67,12 +68,19 @@ func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) // 布尔(兼容现行行为)。查询字段由执行端受信构造。 func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) { a.mu.Lock() - fn2, fn1 := a.fn2, a.fn + fn2, fn1, enrich := a.fn2, a.fn, a.enrich a.mu.Unlock() if fn2 != nil { if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" { return false, nil // 受信上下文字段缺失=不可判定=拒绝 } + if enrich != nil { + var err error + q, err = enrich(q) + if err != nil { + return false, err + } + } return fn2(q) } if fn1 == nil || q.Owner == "" || q.ProjectID == "" { @@ -337,18 +345,22 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten if _, err := h.rolePolicyActor(); err != nil { return extension.RolePolicySnapshot{}, err } - account, err := h.caps.st.GetAccount(userID) + return rolePolicySnapshotForUser(h.caps.st, userID) +} + +func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePolicySnapshot, error) { + account, err := st.GetAccount(userID) if err != nil { return extension.RolePolicySnapshot{}, err } - epoch, err := h.caps.st.AuthorityEpoch() + epoch, err := st.AuthorityEpoch() if err != nil { return extension.RolePolicySnapshot{}, err } snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled, AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}} - view, err := h.caps.st.RoleForUser(userID) + view, err := st.RoleForUser(userID) if err != nil { return extension.RolePolicySnapshot{}, err } @@ -361,8 +373,7 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten snapshot.RoleDescription = view.Role.Description snapshot.RoleEnabled = view.Role.Enabled snapshot.RoleVersion = view.Role.RoleVersion - snapshot.AuthorityEpoch = view.Binding.AuthorityEpoch - permissions, err := h.caps.st.RolePermissions(view.Role.RoleID) + permissions, err := st.RolePermissions(view.Role.RoleID) if err != nil { return extension.RolePolicySnapshot{}, err } @@ -422,6 +433,18 @@ func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string return decision, nil } +func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID string) (extension.SystemActionDecision, error) { + if err := ctx.Err(); err != nil { + return extension.SystemActionDecision{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return extension.SystemActionDecision{}, err + } + decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID) + return extension.SystemActionDecision{ActionID: actionID, Allowed: decision.Allowed, + AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil +} + func (h inprocHost) requireRoleAdminAction(actionID string) error { if h.actor == "" || h.owner != h.actor { return store.ErrAccountForbidden @@ -570,6 +593,82 @@ func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled b return roleAdminRecord(view), err } +func (h inprocHost) ImportRoleAuthority(ctx context.Context, in extension.RoleAuthorityImport, key string) (extension.RoleAuthorityImportResult, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + roles := make([]store.RoleAuthorityImportRole, 0, len(in.Roles)) + for _, role := range in.Roles { + permissions := make([]store.RolePermissionChange, 0, len(role.Permissions)) + for actionID, permission := range role.Permissions { + permissions = append(permissions, store.RolePermissionChange{ActionID: actionID, + Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}) + } + sort.Slice(permissions, func(i, j int) bool { return permissions[i].ActionID < permissions[j].ActionID }) + roles = append(roles, store.RoleAuthorityImportRole{RoleID: role.RoleID, Name: role.Name, + Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion, + Permissions: permissions, Owners: append([]string(nil), role.Owners...)}) + } + result, err := h.caps.st.ImportRoleAuthority(h.actor, store.RoleAuthorityImport{ + SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles}, key) + if err != nil { + return extension.RoleAuthorityImportResult{}, err + } + return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch, + ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil +} + +func (h inprocHost) ReadRoleAuthorityImport(ctx context.Context, sourceSHA string) (extension.RoleAuthorityImportProjection, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + staged, err := h.caps.st.ReadRoleAuthorityImport(h.actor, sourceSHA) + if err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + roles := make([]extension.RoleAdminRecord, 0, len(staged.Roles)) + bindings := map[string]string{} + for _, role := range staged.Roles { + permissions := map[string]extension.RolePermission{} + for _, permission := range role.Permissions { + permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed} + } + roles = append(roles, extension.RoleAdminRecord{RoleID: role.RoleID, Name: role.Name, + Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion, + BindingCount: len(role.Owners), Permissions: permissions}) + for _, owner := range role.Owners { + bindings[owner] = role.RoleID + } + } + epoch, err := h.caps.st.AuthorityEpoch() + if err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + return extension.RoleAuthorityImportProjection{SourceSHA256: staged.SourceSHA256, + AuthorityEpoch: epoch, Roles: roles, Bindings: bindings}, nil +} + +func (h inprocHost) ActivateRoleAuthorityImport(ctx context.Context, sourceSHA string, expectedEpoch int64) (extension.RoleAuthorityImportResult, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + result, err := h.caps.st.ActivateRoleAuthorityImport(h.actor, sourceSHA, expectedEpoch) + if err != nil { + return extension.RoleAuthorityImportResult{}, err + } + return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch, + ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil +} + func (h inprocHost) KVPut(key, value string) error { if err := h.requireEffective(); err != nil { return err diff --git a/internal/app/resource_authz_v2_test.go b/internal/app/resource_authz_v2_test.go index 17dff17..ed3423e 100644 --- a/internal/app/resource_authz_v2_test.go +++ b/internal/app/resource_authz_v2_test.go @@ -71,3 +71,19 @@ func TestCompiledRegistryRejectsV2(t *testing.T) { t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)") } } + +func TestAuthorizeResourceEnrichesEveryInProcessQueryWithCurrentRolePolicy(t *testing.T) { + snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, + Owner: "alice", AccountEnabled: true, RoleID: "role_1", RoleEnabled: true, AuthorityEpoch: 7} + a := projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) { + q.AuthorityEpoch = snapshot.AuthorityEpoch + q.RolePolicy = &snapshot + return q, nil + }} + a.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.AuthorityEpoch == 7 && q.RolePolicy != nil && q.RolePolicy.Owner == q.Actor, nil + }) + if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{Actor: "alice", Action: "search", ResourceType: "knowledge", ResourceID: "k1"}); err != nil || !ok { + t.Fatalf("enriched query ok=%v err=%v", ok, err) + } +} diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go index 1804387..08494f0 100644 --- a/internal/app/role_provider_test.go +++ b/internal/app/role_provider_test.go @@ -60,6 +60,23 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) { if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID { t.Fatalf("decision=%+v err=%v", decision, err) } + systemProvider, ok := any(h).(extension.SystemActionProvider) + if !ok { + t.Fatal("dispatch host missing SystemActionProvider") + } + systemDecision, err := systemProvider.CheckCurrentSystemAction(context.Background(), "system.permissions.manage") + if err != nil || !systemDecision.Allowed || systemDecision.AuthorityEpoch < 1 { + t.Fatalf("system decision=%+v err=%v", systemDecision, err) + } + updated, err := st.ReplaceRolePermissions("admin", role.RoleID, snapshot.RoleVersion, + []store.RolePermissionChange{{ActionID: "document.read", Allowed: false, CapAllowed: true}}, "provider-policy-update") + if err != nil { + t.Fatal(err) + } + refreshed, err := provider.RolePolicyForUser(context.Background(), alice.ID) + if err != nil || refreshed.AuthorityEpoch != updated.AuthorityEpoch || refreshed.RoleVersion != updated.RoleVersion { + t.Fatalf("refreshed=%+v receipt=%+v err=%v", refreshed, updated, err) + } } func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) { diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index 0d0698b..19e8af9 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -73,15 +73,15 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { byAction[q.Action] = q } dl := byAction["download"] - if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" { + if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" || dl.AuthorityEpoch < 1 { t.Fatalf("download 查询六要素不符: %+v", dl) } ls := byAction["list"] - if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" { + if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" || ls.AuthorityEpoch != dl.AuthorityEpoch { t.Fatalf("list 查询不符: %+v", ls) } rd := byAction["read"] - if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid { + if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid || rd.AuthorityEpoch != dl.AuthorityEpoch { t.Fatalf("read(conversation) 查询不符: %+v", rd) } } diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 47999ef..570bd3a 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -98,3 +98,55 @@ func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { } } } + +func TestDelegatedUserActionsReachTransactionalStoreChecks(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + if err := st.CreateAccount("user-manager", "User Manager", "", false); err != nil { + t.Fatal(err) + } + manager, err := st.GetAccountByOwner("user-manager") + if err != nil { + t.Fatal(err) + } + epoch, err := st.AuthorityEpoch() + if err != nil { + t.Fatal(err) + } + grant := do(h, "PUT", "/api/admin/users/"+manager.ID+"/system-grants", + `{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"grant-user-manager","actions":["system.users.view","system.users.create","system.users.edit","system.users.disable"]}`, admin) + if grant.Code != http.StatusOK { + t.Fatalf("grant: %d %s", grant.Code, grant.Body.String()) + } + key, err := st.CreateAPIKey("user-manager", "user-manager-test") + if err != nil { + t.Fatal(err) + } + created := bearerRequest(h, "POST", "/api/admin/users", `{"owner":"managed-user","profile":{"name":"Managed"}}`, key) + if created.Code != http.StatusOK { + t.Fatalf("delegated create: %d %s", created.Code, created.Body.String()) + } + var target struct { + UserID string `json:"userId"` + ProfileVersion int64 `json:"profileVersion"` + StateVersion int64 `json:"stateVersion"` + } + if err := json.Unmarshal(created.Body.Bytes(), &target); err != nil { + t.Fatal(err) + } + search := bearerRequest(h, "GET", "/api/admin/users/search?query=Managed&mode=exact&status=enabled&fields=name&limit=20", "", key) + if search.Code != http.StatusOK || !strings.Contains(search.Body.String(), target.UserID) { + t.Fatalf("delegated search: %d %s", search.Code, search.Body.String()) + } + patched := bearerRequest(h, "PATCH", "/api/admin/users/"+target.UserID+"/profile", + `{"expectedProfileVersion":`+strconv.FormatInt(target.ProfileVersion, 10)+`,"patch":{"department":"研发"}}`, key) + if patched.Code != http.StatusOK { + t.Fatalf("delegated edit: %d %s", patched.Code, patched.Body.String()) + } + disabled := bearerRequest(h, "PUT", "/api/admin/users/"+target.UserID+"/state", + `{"expectedStateVersion":`+strconv.FormatInt(target.StateVersion, 10)+`,"enabled":false}`, key) + if disabled.Code != http.StatusOK { + t.Fatalf("delegated disable: %d %s", disabled.Code, disabled.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bde4b06..f816145 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -141,9 +141,13 @@ func (s *Server) authorizeSharedResource(actor, action, resourceType string, res if actor == "" || action == "" || resourceType == "" || resourceID == "" { return false } + epoch, err := s.st.AuthorityEpoch() + if err != nil { + return false + } ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{ Actor: actor, Action: action, ResourceType: resourceType, - ResourceID: resourceID, Owner: owner, ProjectID: projectID, + ResourceID: resourceID, Owner: owner, ProjectID: projectID, AuthorityEpoch: epoch, }) return err == nil && ok } @@ -2224,8 +2228,12 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { query.ProjectID = id } if s.ProjectAuthzV2 != nil { + epoch, epochErr := s.st.AuthorityEpoch() allow := func(d core.KnowledgeDoc) bool { - ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + if epochErr != nil { + return false + } + ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source, AuthorityEpoch: epoch}) return err == nil && ok } if rs, ok := s.Retriever.(interface { diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go index bef0de9..157a0e3 100644 --- a/internal/store/account_directory.go +++ b/internal/store/account_directory.go @@ -58,8 +58,8 @@ func (s *Store) ListAdminAccountsForActor(actorOwner string) ([]Account, Account return nil, AccountDirectoryRevision{}, err } actor := locked[actorOwner] - if !actor.Enabled || !actor.IsAdmin { - return nil, AccountDirectoryRevision{}, ErrAccountForbidden + if err := s.requireSystemActionTx(tx, actor, "system.users.view"); err != nil { + return nil, AccountDirectoryRevision{}, err } rows, err := tx.Query(s.q(`SELECT ` + accountColumns + ` FROM users ORDER BY owner,id`)) if err != nil { diff --git a/internal/store/account_model.go b/internal/store/account_model.go index 7c81fb6..0063cf4 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -208,13 +208,16 @@ func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVe } defer tx.Rollback() actor, err := s.accountByOwner(tx, actorOwner) - if errors.Is(err, ErrNotFound) || (err == nil && (!actor.Enabled || (!actor.IsAdmin && actor.ID != userID))) { + if errors.Is(err, ErrNotFound) || (err == nil && !actor.Enabled) { return Account{}, ErrAccountForbidden } if err != nil { return Account{}, err } target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) + if errors.Is(err, ErrNotFound) && actor.ID != userID { + return Account{}, ErrAccountForbidden + } if err != nil { return Account{}, err } @@ -223,9 +226,14 @@ func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVe return Account{}, err } actor, target = locked[actorOwner], locked[target.Owner] - if !actor.Enabled || (actor.ID != target.ID && !actor.IsAdmin) { + if !actor.Enabled { return Account{}, ErrAccountForbidden } + if actor.ID != target.ID { + if err := s.requireSystemActionTx(tx, actor, "system.users.edit"); err != nil { + return Account{}, err + } + } if target.ID != userID || target.ProfileVersion != expectedProfileVersion { return Account{}, ErrConflict } @@ -280,8 +288,8 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr return Account{}, err } actor := locked[actorOwner] - if !actor.Enabled || !actor.IsAdmin { - return Account{}, ErrAccountForbidden + if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil { + return Account{}, err } a, err := s.insertPendingAccountTx(tx, owner, profile) if err != nil { diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go index 26f34c7..7158365 100644 --- a/internal/store/account_model_test.go +++ b/internal/store/account_model_test.go @@ -151,6 +151,9 @@ func TestAccountModelProfileAuthorization(t *testing.T) { if _, err := st.db.Exec(st.q(`UPDATE users SET is_admin=? WHERE owner=?`), false, "admin"); err != nil { t.Fatal(err) } + if _, err := st.db.Exec(st.q(`DELETE FROM system_grants WHERE user_id=(SELECT id FROM users WHERE owner=?)`), "admin"); err != nil { + t.Fatal(err) + } if _, err := st.PatchAccountProfile("admin", a.ID, 3, map[string]string{"name": "stale admin"}); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("demoted admin: %v", err) } diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 87deb36..3f7c308 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -1,6 +1,7 @@ package store import ( + "database/sql" "errors" "sort" ) @@ -47,8 +48,8 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio return Account{}, err } actor, target := locked[actorOwner], locked[target.Owner] - if !actor.Enabled || !actor.IsAdmin { - return Account{}, ErrAccountForbidden + if err := s.requireSystemActionTx(tx, actor, "system.users.disable"); err != nil { + return Account{}, err } if target.ID != userID || target.StateVersion != expectedStateVersion || target.Enabled == enabled { return Account{}, ErrConflict @@ -62,6 +63,23 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio return Account{}, ErrConflict } } + if !enabled { + var targetManagesPermissions bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), target.ID, "system.permissions.manage").Scan(&targetManagesPermissions) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return Account{}, err + } + if targetManagesPermissions { + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil { + return Account{}, err + } + if managers <= 1 { + return Account{}, ErrLastPermissionManager + } + } + } result, err := tx.Exec(s.q(`UPDATE users SET enabled=?,state_version=state_version+1,auth_version=auth_version+1 WHERE id=? AND state_version=?`), enabled, userID, expectedStateVersion) if err != nil { return Account{}, err diff --git a/internal/store/migrate.go b/internal/store/migrate.go index b0e275c..5bb6d5b 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -904,9 +904,21 @@ func init() { }, down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown}, }, + migration{version: 36, name: "role_authority_import_stage", + up: map[string][]string{"sqlite": roleAuthorityImportStageUp, "postgres": roleAuthorityImportStageUp}, + down: map[string][]string{"sqlite": {`DROP TABLE role_authority_import_state`}, "postgres": {`DROP TABLE role_authority_import_state`}}, + }, ) } +var roleAuthorityImportStageUp = []string{ + `CREATE TABLE role_authority_import_state ( + id INTEGER PRIMARY KEY,source_sha256 TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'idle', + payload_json TEXT NOT NULL DEFAULT '',staged_by TEXT NOT NULL DEFAULT '',updated_at TEXT NOT NULL DEFAULT '')`, + `INSERT INTO role_authority_import_state(id,source_sha256,state,payload_json,staged_by,updated_at) + VALUES(1,'','idle','','','')`, +} + func rolePolicyFoundationUp(boolType string) []string { statements := []string{ `CREATE TABLE roles ( diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 2e5e67d..c775bdb 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -40,6 +40,7 @@ var migrationBreaksOldReaders = map[int]bool{ 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段 35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏 + 36: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 8860ab7..1006901 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "sort" "strings" "time" @@ -86,6 +87,28 @@ type RoleBindingView struct { Role *RoleRecord `json:"role,omitempty"` } +type RoleAuthorityImportRole struct { + RoleID string + Name string + Description string + Enabled bool + RoleVersion int64 + Permissions []RolePermissionChange + Owners []string +} + +type RoleAuthorityImport struct { + SourceSchemaVersion int + SourceSHA256 string + Roles []RoleAuthorityImportRole +} + +type RoleAuthorityImportResult struct { + AuthorityEpoch int64 + ImportedRoles int + ImportedUsers int +} + func normalizeRoleName(name string) string { return strings.ToLower(strings.TrimSpace(name)) } @@ -186,6 +209,21 @@ func (s *Store) roleTx(tx *sql.Tx, roleID string, lock bool) (RoleRecord, error) return scanRole(tx.QueryRow(s.q(query), roleID)) } +func (s *Store) requireRoleAuthorityWritableTx(tx *sql.Tx) error { + query := `SELECT state FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var state string + if err := tx.QueryRow(query).Scan(&state); err != nil { + return err + } + if state == "staged" { + return ErrConflict + } + return nil +} + func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) { name, description, err := validateRoleText(in.Name, in.Description) if err != nil { @@ -193,6 +231,9 @@ func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) { } var out RoleRecord err = s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } now := nowISO() roleID := newID("role") _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) @@ -215,6 +256,9 @@ func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) { func (s *Store) PatchRole(actor, roleID string, expected int64, patch RolePatch) (RoleRecord, error) { var out RoleRecord err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } current, err := s.roleTx(tx, roleID, true) if err != nil { return err @@ -307,6 +351,9 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha }{roleID, expected, changes}) var out PolicyReceipt err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, &out) if err != nil || replayed { return err @@ -359,6 +406,9 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64 }{userID, roleID, expectedState}) var out RoleBinding err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, &out) if err != nil || replayed { return err @@ -571,6 +621,33 @@ func (s *Store) SystemGrantAllowed(owner, actionID string) (bool, error) { return allowed, err } +// requireSystemActionTx rechecks delegated system authority in the same +// transaction that consumes it. Callers lock the actor account first, keeping +// the account-before-policy lock order used by policyWrite. +func (s *Store) requireSystemActionTx(tx *sql.Tx, actor Account, actionID string) error { + descriptor, ok := catalog.DescriptorByID(actionID) + if !ok || descriptor.Scope != catalog.ScopeSystem || !actor.Enabled { + return ErrAccountForbidden + } + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + if actor.IsAdmin { + return nil + } + var allowed bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), actor.ID, actionID).Scan(&allowed) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !allowed) { + return ErrAccountForbidden + } + return err +} + func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error) { var allowed, capAllowed bool err := s.db.QueryRow(s.q(`SELECT p.allowed,p.cap_allowed FROM users u @@ -633,3 +710,252 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a }) return out, err } + +// ImportRoleAuthority stages a validated, invisible role projection. Live role +// authorization remains unchanged until ActivateRoleAuthorityImport commits it. +func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key string) (RoleAuthorityImportResult, error) { + if actor == "" || key == "" || in.SourceSchemaVersion < 1 || len(in.SourceSHA256) != 64 { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + roles := append([]RoleAuthorityImportRole(nil), in.Roles...) + sort.Slice(roles, func(i, j int) bool { return roles[i].RoleID < roles[j].RoleID }) + roleIDs, names, boundOwners := map[string]bool{}, map[string]bool{}, map[string]bool{} + owners := []string{actor} + for i := range roles { + role := &roles[i] + role.RoleID = strings.TrimSpace(role.RoleID) + var err error + role.Name, role.Description, err = validateRoleText(role.Name, role.Description) + if err != nil || role.RoleID == "" || role.RoleVersion < 1 || roleIDs[role.RoleID] || names[normalizeRoleName(role.Name)] { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + roleIDs[role.RoleID], names[normalizeRoleName(role.Name)] = true, true + seenActions := map[string]bool{} + for _, permission := range role.Permissions { + descriptor, ok := catalog.DescriptorByID(permission.ActionID) + if !ok || descriptor.Protected || seenActions[permission.ActionID] || (permission.Allowed && !permission.CapAllowed) { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + seenActions[permission.ActionID] = true + } + sort.Strings(role.Owners) + for _, owner := range role.Owners { + if owner == "" || boundOwners[owner] { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + boundOwners[owner] = true + owners = append(owners, owner) + } + } + normalized := RoleAuthorityImport{SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles} + payload, err := json.Marshal(normalized) + if err != nil { + return RoleAuthorityImportResult{}, err + } + requestHash := policyRequestHash(normalized) + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImportResult{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return RoleAuthorityImportResult{}, err + } + actorAccount := accounts[actor] + if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil { + return RoleAuthorityImportResult{}, err + } + var receipt PolicyReceipt + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, &receipt) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if replayed { + return RoleAuthorityImportResult{AuthorityEpoch: receipt.AuthorityEpoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil + } + var roleCount, bindingCount int + if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if roleCount != 0 || bindingCount != 0 { + return RoleAuthorityImportResult{}, ErrConflict + } + var epoch int64 + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return RoleAuthorityImportResult{}, err + } + stateQuery := `SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + stateQuery += ` FOR UPDATE` + } + var sourceSHA, state, storedPayload string + if err := tx.QueryRow(stateQuery).Scan(&sourceSHA, &state, &storedPayload); err != nil { + return RoleAuthorityImportResult{}, err + } + if state != "idle" { + if sourceSHA != in.SourceSHA256 || storedPayload != string(payload) { + return RoleAuthorityImportResult{}, ErrConflict + } + } else if _, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET source_sha256=?,state='staged',payload_json=?,staged_by=?,updated_at=? WHERE id=1 AND state='idle'`), + in.SourceSHA256, string(payload), actorAccount.ID, nowISO()); err != nil { + return RoleAuthorityImportResult{}, err + } + receipt = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch} + if err := s.saveReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, receipt); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil +} + +func (s *Store) ReadRoleAuthorityImport(actor, sourceSHA string) (RoleAuthorityImport, error) { + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImport{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, actor) + if err != nil { + return RoleAuthorityImport{}, err + } + if err := s.requireSystemActionTx(tx, accounts[actor], "system.permissions.manage"); err != nil { + return RoleAuthorityImport{}, err + } + var storedSHA, state, payload string + if err := tx.QueryRow(`SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1`).Scan(&storedSHA, &state, &payload); err != nil { + return RoleAuthorityImport{}, err + } + if storedSHA != sourceSHA || (state != "staged" && state != "active") { + return RoleAuthorityImport{}, ErrConflict + } + var out RoleAuthorityImport + if err := json.Unmarshal([]byte(payload), &out); err != nil { + return RoleAuthorityImport{}, err + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImport{}, err + } + return out, nil +} + +func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpoch int64) (RoleAuthorityImportResult, error) { + staged, err := s.ReadRoleAuthorityImport(actor, sourceSHA) + if err != nil { + return RoleAuthorityImportResult{}, err + } + owners := []string{actor} + for _, role := range staged.Roles { + owners = append(owners, role.Owners...) + } + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImportResult{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return RoleAuthorityImportResult{}, err + } + actorAccount := accounts[actor] + if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil { + return RoleAuthorityImportResult{}, err + } + var epoch int64 + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return RoleAuthorityImportResult{}, err + } + stateQuery := `SELECT source_sha256,state FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + stateQuery += ` FOR UPDATE` + } + var storedSHA, state string + if err := tx.QueryRow(stateQuery).Scan(&storedSHA, &state); err != nil { + return RoleAuthorityImportResult{}, err + } + if storedSHA != sourceSHA { + return RoleAuthorityImportResult{}, ErrConflict + } + if state == "active" { + return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(staged.Roles), ImportedUsers: len(owners) - 1}, nil + } + if epoch != expectedEpoch { + return RoleAuthorityImportResult{}, ErrConflict + } + if state != "staged" { + return RoleAuthorityImportResult{}, ErrConflict + } + var roleCount, bindingCount int + if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if roleCount != 0 || bindingCount != 0 { + return RoleAuthorityImportResult{}, ErrConflict + } + now := nowISO() + importedUsers := 0 + for _, role := range staged.Roles { + if _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), role.RoleID, role.Name, normalizeRoleName(role.Name), role.Description, role.Enabled, + role.RoleVersion, actorAccount.ID, actorAccount.ID, now, now); err != nil { + return RoleAuthorityImportResult{}, err + } + for _, permission := range role.Permissions { + if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?)`), role.RoleID, permission.ActionID, permission.Allowed, permission.CapAllowed, 1, actorAccount.ID, now); err != nil { + return RoleAuthorityImportResult{}, err + } + } + for _, owner := range role.Owners { + target := accounts[owner] + if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), newID("binding"), target.ID, role.RoleID, true, 1, actorAccount.ID, now, ""); err != nil { + return RoleAuthorityImportResult{}, err + } + result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=?`), target.ID, target.StateVersion) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + if err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{}, ErrConflict + } + importedUsers++ + } + } + 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 + } + stateResult, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET state='active',updated_at=? WHERE id=1 AND source_sha256=? AND state='staged'`), now, sourceSHA) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if n, err := stateResult.RowsAffected(); err != nil || n != 1 { + if err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{}, ErrConflict + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil +} diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go index 3e103eb..ee192ff 100644 --- a/internal/store/role_policy_migration_test.go +++ b/internal/store/role_policy_migration_test.go @@ -80,6 +80,26 @@ func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) { } } +func TestRoleAuthorityImportStageUpgradesExistingV35(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(35); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil { + t.Fatalf("v35 role authority was removed: %v", err) + } + if _, err := st.db.Exec(`SELECT state FROM role_authority_import_state WHERE id=1`); err == nil { + t.Fatal("v36 staging table remained after rollback to v35") + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + var state string + if err := st.db.QueryRow(`SELECT state FROM role_authority_import_state WHERE id=1`).Scan(&state); err != nil || state != "idle" { + t.Fatalf("v35 to v36 state=%q err=%v", state, err) + } +} + func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) { st := openTestStore(t) if err := st.MigrateDown(34); err != nil { diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index f16865e..99665f4 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "strings" "sync" "testing" ) @@ -147,6 +148,23 @@ func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) { } } +func TestDisableProtectsFinalPermissionManager(t *testing.T) { + st := openTestStore(t) + admin, alice, _ := rolePolicyAccounts(t, st) + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "grant-final-manager"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-root-explicit-grants"); err != nil { + t.Fatal(err) + } + alice, _ = st.GetAccount(alice.ID) + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); !errors.Is(err, ErrLastPermissionManager) { + t.Fatalf("disabled final permission manager: %v", err) + } +} + func TestPatchRoleKeepsStableIDAndDisablingBlocksAssignment(t *testing.T) { st := openTestStore(t) _, alice, _ := rolePolicyAccounts(t, st) @@ -188,3 +206,56 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) { t.Fatalf("changed request reused idempotency key: %v", err) } } + +func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) { + st := openTestStore(t) + for _, row := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(row.owner, row.owner, "hash", row.admin); err != nil { + t.Fatal(err) + } + } + in := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("a", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_engineer", Name: "软件工程师", Description: "迁移职位", Enabled: true, RoleVersion: 4, + Permissions: []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, + Owners: []string{"alice", "bob"}, + }}} + result, err := st.ImportRoleAuthority("admin", in, "import-role-authority") + if err != nil || result.ImportedRoles != 1 || result.ImportedUsers != 2 { + t.Fatalf("result=%+v err=%v", result, err) + } + if _, err := st.RoleAdmin("role_legacy_engineer"); !errors.Is(err, ErrNotFound) { + t.Fatalf("staged role became live before activation: %v", err) + } + if _, err := st.CreateRole("admin", RoleCreate{Name: "并发角色"}); !errors.Is(err, ErrConflict) { + t.Fatalf("live role write bypassed staged migration fence: %v", err) + } + staged, err := st.ReadRoleAuthorityImport("admin", in.SourceSHA256) + if err != nil || len(staged.Roles) != 1 || len(staged.Roles[0].Owners) != 2 { + t.Fatalf("staged=%+v err=%v", staged, err) + } + activated, err := st.ActivateRoleAuthorityImport("admin", in.SourceSHA256, result.AuthorityEpoch) + if err != nil || activated.AuthorityEpoch != result.AuthorityEpoch+1 { + t.Fatalf("activated=%+v err=%v", activated, err) + } + view, err := st.RoleAdmin("role_legacy_engineer") + if err != nil || view.RoleVersion != 4 || view.BindingCount != 2 || view.Description != "迁移职位" { + t.Fatalf("view=%+v err=%v", view, err) + } + for _, owner := range []string{"alice", "bob"} { + account, _ := st.GetAccountByOwner(owner) + binding, err := st.RoleForUser(account.ID) + if err != nil || binding.Role == nil || binding.Role.RoleID != "role_legacy_engineer" { + t.Fatalf("owner=%s binding=%+v err=%v", owner, binding, err) + } + } + replayed, err := st.ImportRoleAuthority("admin", in, "import-role-authority") + if err != nil || replayed.AuthorityEpoch != result.AuthorityEpoch { + t.Fatalf("replay=%+v err=%v", replayed, err) + } + if _, err := st.ImportRoleAuthority("admin", in, "second-import"); !errors.Is(err, ErrConflict) { + t.Fatalf("non-empty authority accepted a second import: %v", err) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 2f63ae4..feac709 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -38,7 +38,9 @@ import ( // AccountDirectoryRegistrar for scoped, current account candidate decisions. // 2.14.0-experimental: optional RolePolicyProvider for the Backend-owned // single-effective-role policy consumed by trusted in-process domains. -const SurfaceVersion = "2.14.0-experimental" +// 2.15.0-experimental: resource queries carry the current authority epoch so +// downstream caches can reject stale role snapshots. +const SurfaceVersion = "2.15.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -60,6 +62,12 @@ type ResourceAuthzQuery struct { // Source is optional trusted resource provenance, populated from stored knowledge rows. Source string `json:"source,omitempty"` ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 + // AuthorityEpoch is read from Backend authority_state at the point of use. + // Downstream policy caches must reject snapshots from another epoch. + AuthorityEpoch int64 `json:"authorityEpoch,omitempty"` + // RolePolicy is the current Backend snapshot for Actor on trusted in-process + // calls. It lets downstream resource authorization refresh without a command. + RolePolicy *RolePolicySnapshot `json:"rolePolicy,omitempty"` } // ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index f9dab8b..203dbe3 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -44,6 +44,19 @@ type RolePolicyProvider interface { CheckRoleAction(context.Context, string, string) (RoleActionDecision, error) } +type SystemActionDecision struct { + ActionID string `json:"actionId"` + Allowed bool `json:"allowed"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Reason string `json:"reason,omitempty"` +} + +// SystemActionProvider exposes only the current Host-bound actor's effective +// system action. It never accepts a caller-supplied actor. +type SystemActionProvider interface { + CheckCurrentSystemAction(context.Context, string) (SystemActionDecision, error) +} + type RoleAdminRecord struct { RoleID string `json:"roleId"` Name string `json:"name"` @@ -62,6 +75,35 @@ type RoleBindingResult struct { AuthorityEpoch int64 `json:"authorityEpoch"` } +type RoleAuthorityImportRole struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + RoleVersion int64 `json:"roleVersion"` + Permissions map[string]RolePermission `json:"permissions"` + Owners []string `json:"owners"` +} + +type RoleAuthorityImport struct { + SourceSchemaVersion int `json:"sourceSchemaVersion"` + SourceSHA256 string `json:"sourceSha256"` + Roles []RoleAuthorityImportRole `json:"roles"` +} + +type RoleAuthorityImportResult struct { + AuthorityEpoch int64 `json:"authorityEpoch"` + ImportedRoles int `json:"importedRoles"` + ImportedUsers int `json:"importedUsers"` +} + +type RoleAuthorityImportProjection struct { + SourceSHA256 string `json:"sourceSha256"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Roles []RoleAdminRecord `json:"roles"` + Bindings map[string]string `json:"bindings"` // stable owner -> roleId +} + // RolePolicyAdmin is a temporary compatibility surface for trusted RDM // commands while the UI moves to Backend HTTP APIs. The Host binds the actor. type RolePolicyAdmin interface { @@ -70,4 +112,7 @@ type RolePolicyAdmin interface { SetRolePermission(context.Context, string, string, bool, bool, string) (RoleAdminRecord, error) AssignRoleByOwner(context.Context, string, string, string) (RoleBindingResult, error) SetRoleEnabled(context.Context, string, bool) (RoleAdminRecord, error) + ImportRoleAuthority(context.Context, RoleAuthorityImport, string) (RoleAuthorityImportResult, error) + ReadRoleAuthorityImport(context.Context, string) (RoleAuthorityImportProjection, error) + ActivateRoleAuthorityImport(context.Context, string, int64) (RoleAuthorityImportResult, error) } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index a738dbe..d7b8813 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -81,12 +81,19 @@ ResourceAuthorizerV2 ResourceAuthzQuery RoleActionDecision RoleAdminRecord +RoleAuthorityImport +RoleAuthorityImportProjection +RoleAuthorityImportResult +RoleAuthorityImportRole RoleBindingResult RolePermission RolePolicyAdmin +RolePolicyAdmin.ActivateRoleAuthorityImport RolePolicyAdmin.AssignRoleByOwner RolePolicyAdmin.CreateRole +RolePolicyAdmin.ImportRoleAuthority RolePolicyAdmin.ListRoles +RolePolicyAdmin.ReadRoleAuthorityImport RolePolicyAdmin.SetRoleEnabled RolePolicyAdmin.SetRolePermission RolePolicyProvider @@ -105,3 +112,6 @@ SurfaceHost.SurfaceList SurfaceHost.SurfaceSave SurfaceSaveRequest SurfaceVersion +SystemActionDecision +SystemActionProvider +SystemActionProvider.CheckCurrentSystemAction