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
This commit is contained in:
weichen
2026-10-15 02:27:50 +00:00
parent b2a1cd0a60
commit 06ef17a70a
20 changed files with 748 additions and 22 deletions
+1 -1
View File
@@ -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",
+13 -1
View File
@@ -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 {
+105 -6
View File
@@ -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
+16
View File
@@ -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)
}
}
+17
View File
@@ -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) {
@@ -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)
}
}
@@ -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())
}
}
+10 -2
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+12 -4
View File
@@ -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 {
+3
View File
@@ -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)
}
+20 -2
View File
@@ -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
+12
View File
@@ -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 (
+1
View File
@@ -40,6 +40,7 @@ var migrationBreaksOldReaders = map[int]bool{
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
36: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+326
View File
@@ -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
}
@@ -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 {
+71
View File
@@ -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)
}
}
+9 -1
View File
@@ -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 布尔)之上的
+45
View File
@@ -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)
}
+10
View File
@@ -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