diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 7ffd00c..51abd53 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -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 diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go new file mode 100644 index 0000000..1e45377 --- /dev/null +++ b/internal/app/role_provider_test.go @@ -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) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index bbfe1d1..2f63ae4 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -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") diff --git a/pkg/extension/host.go b/pkg/extension/host.go index c528b81..489439c 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -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 diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go new file mode 100644 index 0000000..2bd374d --- /dev/null +++ b/pkg/extension/role_policy.go @@ -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) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index ca7118a..54a07aa 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -79,6 +79,14 @@ ResetCompiled ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery +RoleActionDecision +RolePermission +RolePolicyProvider +RolePolicyProvider.CheckRoleAction +RolePolicyProvider.CurrentRolePolicy +RolePolicyProvider.RolePolicyForUser +RolePolicySchemaVersion +RolePolicySnapshot SetRunner SetScopedHostFactory SurfaceHost