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"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/runtime"
|
||||
@@ -305,6 +306,109 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
|
||||
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 {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
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.
|
||||
// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process
|
||||
// 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 无法启动服务。
|
||||
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 获取,断言失败=明确报错):
|
||||
// - AdminProvider — EXT-18:调用方平台管理角色查询
|
||||
// - SurfaceHost — EXT-17:页面保存/读取
|
||||
// - RolePolicyProvider — Backend 权威职位与动作默认/上限
|
||||
type Host interface {
|
||||
// PluginID 返回注册处理器的插件标识(形如 rdm)。
|
||||
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
|
||||
ResourceAuthorizerV2
|
||||
ResourceAuthzQuery
|
||||
RoleActionDecision
|
||||
RolePermission
|
||||
RolePolicyProvider
|
||||
RolePolicyProvider.CheckRoleAction
|
||||
RolePolicyProvider.CurrentRolePolicy
|
||||
RolePolicyProvider.RolePolicyForUser
|
||||
RolePolicySchemaVersion
|
||||
RolePolicySnapshot
|
||||
SetRunner
|
||||
SetScopedHostFactory
|
||||
SurfaceHost
|
||||
|
||||
Reference in New Issue
Block a user