Expose the role policy provider

Why: RDM needs a trusted, non-secret view of Backend-owned role policy without importing Backend internals or accepting a caller-supplied actor.

Evidence: The full Backend test suite passed, including public-surface and provider contract tests.

Impact: Adds optional extension surface 2.14 with actor-bound role snapshots and action decisions.

Refs: Focused role and permission system task 4
This commit is contained in:
weichen
2026-10-14 17:09:11 +00:00
parent 4d1ca60067
commit 5abd851256
6 changed files with 236 additions and 1 deletions
+104
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"sync" "sync"
"agent-system-backend/internal/authz/catalog"
"agent-system-backend/internal/ingest" "agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag" "agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime" "agent-system-backend/internal/runtime"
@@ -305,6 +306,109 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
return entries, nil return entries, nil
} }
func (h inprocHost) rolePolicyActor() (store.Account, error) {
if h.actor == "" {
return store.Account{}, store.ErrAccountForbidden
}
account, err := h.caps.st.GetAccountByOwner(h.actor)
if err != nil || !account.Enabled {
return store.Account{}, store.ErrAccountForbidden
}
return account, nil
}
func (h inprocHost) CurrentRolePolicy(ctx context.Context) (extension.RolePolicySnapshot, error) {
if err := ctx.Err(); err != nil {
return extension.RolePolicySnapshot{}, err
}
actor, err := h.rolePolicyActor()
if err != nil {
return extension.RolePolicySnapshot{}, err
}
return h.RolePolicyForUser(ctx, actor.ID)
}
func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (extension.RolePolicySnapshot, error) {
if err := ctx.Err(); err != nil {
return extension.RolePolicySnapshot{}, err
}
if _, err := h.rolePolicyActor(); err != nil {
return extension.RolePolicySnapshot{}, err
}
account, err := h.caps.st.GetAccount(userID)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
epoch, err := h.caps.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)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
if view.Binding == nil || view.Role == nil {
return snapshot, nil
}
snapshot.BindingID = view.Binding.BindingID
snapshot.RoleID = view.Role.RoleID
snapshot.RoleName = view.Role.Name
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)
if err != nil {
return extension.RolePolicySnapshot{}, err
}
for _, permission := range permissions {
snapshot.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
}
return snapshot, nil
}
func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) {
decision := extension.RoleActionDecision{ActionID: actionID}
if _, ok := catalog.DescriptorByID(actionID); !ok {
decision.Reason = "unknown-action"
return decision, nil
}
snapshot, err := h.RolePolicyForUser(ctx, userID)
if err != nil {
return decision, err
}
decision.RoleID = snapshot.RoleID
decision.AuthorityEpoch = snapshot.AuthorityEpoch
if !snapshot.AccountEnabled {
decision.Reason = "account-disabled"
return decision, nil
}
if snapshot.RoleID == "" {
decision.Reason = "role-unassigned"
return decision, nil
}
if !snapshot.RoleEnabled {
decision.Reason = "role-disabled"
return decision, nil
}
permission, ok := snapshot.Permissions[actionID]
if !ok {
decision.Reason = "action-unconfigured"
return decision, nil
}
decision.Allowed = permission.Allowed
decision.CapAllowed = permission.CapAllowed
if !permission.CapAllowed {
decision.Reason = "role-cap"
} else if !permission.Allowed {
decision.Reason = "role-default-deny"
}
return decision, nil
}
func (h inprocHost) KVPut(key, value string) error { func (h inprocHost) KVPut(key, value string) error {
if err := h.requireEffective(); err != nil { if err := h.requireEffective(); err != nil {
return err return err
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"context"
"errors"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
role, err := st.CreateRole("admin", store.RoleCreate{Name: "软件工程师", Description: "负责软件研发"})
if err != nil {
t.Fatal(err)
}
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "provider-policy")
if err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "provider-binding"); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"}
provider, ok := any(h).(extension.RolePolicyProvider)
if !ok {
t.Fatal("dispatch host missing RolePolicyProvider")
}
snapshot, err := provider.RolePolicyForUser(context.Background(), alice.ID)
if err != nil {
t.Fatal(err)
}
if snapshot.SchemaVersion != extension.RolePolicySchemaVersion || snapshot.UserID != alice.ID || !snapshot.AccountEnabled ||
snapshot.RoleID != role.RoleID || snapshot.RoleName != "软件工程师" || snapshot.RoleDescription != "负责软件研发" ||
!snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch {
t.Fatalf("snapshot=%+v", snapshot)
}
permission := snapshot.Permissions["document.read"]
if !permission.Allowed || !permission.CapAllowed {
t.Fatalf("permission=%+v", permission)
}
decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read")
if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID {
t.Fatalf("decision=%+v err=%v", decision, err)
}
}
func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
t.Fatal(err)
}
alice, _ := st.GetAccountByOwner("alice")
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: ""}
provider := any(h).(extension.RolePolicyProvider)
if _, err := provider.RolePolicyForUser(context.Background(), alice.ID); !errors.Is(err, store.ErrAccountForbidden) {
t.Fatalf("empty actor provider: %v", err)
}
}
+3 -1
View File
@@ -36,7 +36,9 @@ import (
// ResourceAuthzQuery.Source for per-document publication authorization. // ResourceAuthzQuery.Source for per-document publication authorization.
// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process // 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process
// AccountDirectoryRegistrar for scoped, current account candidate decisions. // AccountDirectoryRegistrar for scoped, current account candidate decisions.
const SurfaceVersion = "2.13.0-experimental" // 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"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+1
View File
@@ -41,6 +41,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
// 可选能力接口(type assertion 获取,断言失败=明确报错): // 可选能力接口(type assertion 获取,断言失败=明确报错):
// - AdminProvider — EXT-18:调用方平台管理角色查询 // - AdminProvider — EXT-18:调用方平台管理角色查询
// - SurfaceHost — EXT-17:页面保存/读取 // - SurfaceHost — EXT-17:页面保存/读取
// - RolePolicyProvider — Backend 权威职位与动作默认/上限
type Host interface { type Host interface {
// PluginID 返回注册处理器的插件标识(形如 rdm)。 // PluginID 返回注册处理器的插件标识(形如 rdm)。
PluginID() string PluginID() string
+44
View File
@@ -0,0 +1,44 @@
package extension
import "context"
const RolePolicySchemaVersion = 1
type RolePermission struct {
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
}
// RolePolicySnapshot contains authorization metadata only. It intentionally
// excludes profile contact fields and credentials.
type RolePolicySnapshot struct {
SchemaVersion int `json:"schemaVersion"`
UserID string `json:"userId"`
Owner string `json:"owner"`
AccountEnabled bool `json:"accountEnabled"`
BindingID string `json:"bindingId,omitempty"`
RoleID string `json:"roleId,omitempty"`
RoleName string `json:"roleName,omitempty"`
RoleDescription string `json:"roleDescription,omitempty"`
RoleEnabled bool `json:"roleEnabled"`
RoleVersion int64 `json:"roleVersion,omitempty"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Permissions map[string]RolePermission `json:"permissions"`
}
type RoleActionDecision struct {
ActionID string `json:"actionId"`
RoleID string `json:"roleId,omitempty"`
Allowed bool `json:"allowed"`
CapAllowed bool `json:"capAllowed"`
AuthorityEpoch int64 `json:"authorityEpoch"`
Reason string `json:"reason,omitempty"`
}
// RolePolicyProvider is optional on a trusted dispatch Host. Its actor is
// bound by the Backend; callers cannot supply or replace that actor.
type RolePolicyProvider interface {
CurrentRolePolicy(context.Context) (RolePolicySnapshot, error)
RolePolicyForUser(context.Context, string) (RolePolicySnapshot, error)
CheckRoleAction(context.Context, string, string) (RoleActionDecision, error)
}
+8
View File
@@ -79,6 +79,14 @@ ResetCompiled
ResourceAuthorizer ResourceAuthorizer
ResourceAuthorizerV2 ResourceAuthorizerV2
ResourceAuthzQuery ResourceAuthzQuery
RoleActionDecision
RolePermission
RolePolicyProvider
RolePolicyProvider.CheckRoleAction
RolePolicyProvider.CurrentRolePolicy
RolePolicyProvider.RolePolicyForUser
RolePolicySchemaVersion
RolePolicySnapshot
SetRunner SetRunner
SetScopedHostFactory SetScopedHostFactory
SurfaceHost SurfaceHost