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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user