Add Backend role policy authority for IAM U4 #213
@@ -284,7 +284,8 @@ func cmdBackup(args []string) error {
|
||||
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
|
||||
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
|
||||
"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", "project_work_outbox", "events", "artifacts",
|
||||
"document_render_deliveries",
|
||||
"hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
|
||||
+13
-1
@@ -609,7 +609,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{}
|
||||
workObserverSlot := &projectWorkObserverSlot{}
|
||||
directorySlot, err := newAccountDirectorySlot(st)
|
||||
|
||||
+365
-1
@@ -5,10 +5,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authzsvc "agent-system-backend/internal/authz"
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/rag"
|
||||
@@ -46,6 +49,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) {
|
||||
@@ -71,12 +75,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 == "" {
|
||||
@@ -316,6 +327,359 @@ 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
|
||||
}
|
||||
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 := 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 := 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
|
||||
permissions, err := 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) RolePolicyForOwner(ctx context.Context, owner string) (extension.RolePolicySnapshot, error) {
|
||||
if _, err := h.rolePolicyActor(); err != nil {
|
||||
return extension.RolePolicySnapshot{}, err
|
||||
}
|
||||
account, err := h.caps.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
return extension.RolePolicySnapshot{}, err
|
||||
}
|
||||
return h.RolePolicyForUser(ctx, account.ID)
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID)
|
||||
if !decision.Allowed {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roleAdminRecord(view store.RoleAdminView) extension.RoleAdminRecord {
|
||||
out := extension.RoleAdminRecord{RoleID: view.RoleID, Name: view.Name, Description: view.Description,
|
||||
Enabled: view.Enabled, RoleVersion: view.RoleVersion, BindingCount: view.BindingCount,
|
||||
Permissions: map[string]extension.RolePermission{}}
|
||||
for _, permission := range view.Permissions {
|
||||
out.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.requireRoleAdminAction("system.roles.view"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []extension.RoleAdminRecord{}
|
||||
cursor := ""
|
||||
for {
|
||||
roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
result = append(result, roleAdminRecord(role))
|
||||
}
|
||||
if next == "" {
|
||||
return result, nil
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
role, err := h.caps.st.CreateRole(h.actor, store.RoleCreate{Name: name, Description: description})
|
||||
if err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
view, err := h.caps.st.RoleAdmin(role.RoleID)
|
||||
return roleAdminRecord(view), err
|
||||
}
|
||||
|
||||
func (h inprocHost) SetRolePermission(ctx context.Context, roleID, actionID string, allowed, capAllowed bool, key string) (extension.RoleAdminRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
view, err := h.caps.st.RoleAdmin(roleID)
|
||||
if err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
changes := make([]store.RolePermissionChange, 0, len(view.Permissions)+1)
|
||||
found := false
|
||||
for _, permission := range view.Permissions {
|
||||
if permission.ActionID == actionID {
|
||||
found = true
|
||||
if permission.Allowed == allowed && permission.CapAllowed == capAllowed {
|
||||
return roleAdminRecord(view), nil
|
||||
}
|
||||
permission.Allowed, permission.CapAllowed = allowed, capAllowed
|
||||
}
|
||||
changes = append(changes, store.RolePermissionChange{ActionID: permission.ActionID, Allowed: permission.Allowed, CapAllowed: permission.CapAllowed})
|
||||
}
|
||||
if !found {
|
||||
changes = append(changes, store.RolePermissionChange{ActionID: actionID, Allowed: allowed, CapAllowed: capAllowed})
|
||||
}
|
||||
if key == "" {
|
||||
return extension.RoleAdminRecord{}, store.ErrInvalidRolePolicy
|
||||
}
|
||||
if _, err := h.caps.st.ReplaceRolePermissions(h.actor, roleID, view.RoleVersion, changes, key+":"+strconv.FormatInt(view.RoleVersion, 10)); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
view, err = h.caps.st.RoleAdmin(roleID)
|
||||
return roleAdminRecord(view), err
|
||||
}
|
||||
|
||||
func (h inprocHost) AssignRoleByOwner(ctx context.Context, owner, roleID, key string) (extension.RoleBindingResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.RoleBindingResult{}, err
|
||||
}
|
||||
if err := h.requireRoleAdminAction("system.roles.assign"); err != nil {
|
||||
return extension.RoleBindingResult{}, err
|
||||
}
|
||||
target, err := h.caps.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
return extension.RoleBindingResult{}, err
|
||||
}
|
||||
current, err := h.caps.st.RoleForUser(target.ID)
|
||||
if err != nil {
|
||||
return extension.RoleBindingResult{}, err
|
||||
}
|
||||
if current.Binding != nil && current.Binding.RoleID == roleID {
|
||||
return extension.RoleBindingResult{BindingID: current.Binding.BindingID, UserID: target.ID, RoleID: roleID,
|
||||
UserStateVersion: current.Binding.UserStateVersion, AuthorityEpoch: current.Binding.AuthorityEpoch}, nil
|
||||
}
|
||||
if key == "" {
|
||||
return extension.RoleBindingResult{}, store.ErrInvalidRolePolicy
|
||||
}
|
||||
binding, err := h.caps.st.AssignUserRole(h.actor, target.ID, roleID, target.StateVersion, key+":"+strconv.FormatInt(target.StateVersion, 10))
|
||||
if err != nil {
|
||||
return extension.RoleBindingResult{}, err
|
||||
}
|
||||
return extension.RoleBindingResult{BindingID: binding.BindingID, UserID: binding.UserID, RoleID: binding.RoleID,
|
||||
UserStateVersion: binding.UserStateVersion, AuthorityEpoch: binding.AuthorityEpoch}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled bool) (extension.RoleAdminRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
if err := h.requireRoleAdminAction("system.roles.manage"); err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
view, err := h.caps.st.RoleAdmin(roleID)
|
||||
if err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
if view.Enabled == enabled {
|
||||
return roleAdminRecord(view), nil
|
||||
}
|
||||
role, err := h.caps.st.PatchRole(h.actor, roleID, view.RoleVersion, store.RolePatch{Enabled: &enabled})
|
||||
if err != nil {
|
||||
return extension.RoleAdminRecord{}, err
|
||||
}
|
||||
view, err = h.caps.st.RoleAdmin(role.RoleID)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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)
|
||||
}
|
||||
byOwner, err := provider.RolePolicyForOwner(context.Background(), "alice")
|
||||
if err != nil || byOwner.UserID != alice.ID || byOwner.RoleID != role.RoleID {
|
||||
t.Fatalf("owner policy=%+v err=%v", byOwner, 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)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/role-admin.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)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"}
|
||||
admin, ok := any(h).(extension.RolePolicyAdmin)
|
||||
if !ok {
|
||||
t.Fatal("dispatch host missing RolePolicyAdmin")
|
||||
}
|
||||
role, err := admin.CreateRole(context.Background(), "软件工程师", "负责软件研发")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action")
|
||||
if err != nil || !role.Permissions["document.read"].Allowed {
|
||||
t.Fatalf("role=%+v err=%v", role, err)
|
||||
}
|
||||
binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign")
|
||||
if err != nil || binding.RoleID != role.RoleID {
|
||||
t.Fatalf("binding=%+v err=%v", binding, err)
|
||||
}
|
||||
roles, err := admin.ListRoles(context.Background())
|
||||
if err != nil || len(roles) != 1 || roles[0].BindingCount != 1 {
|
||||
t.Fatalf("roles=%+v err=%v", roles, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package catalog owns the trusted action catalog used by system, role, and
|
||||
// project authorization. Callers cannot invent an action or its scope.
|
||||
package catalog
|
||||
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeSystem Scope = "system"
|
||||
ScopeProject Scope = "project"
|
||||
)
|
||||
|
||||
type Descriptor struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Group string `json:"group"`
|
||||
Scope Scope `json:"scope"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
var descriptors = []Descriptor{
|
||||
{ActionID: "system.settings.read", Label: "查看系统设置", Description: "查看系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.settings.write", Label: "修改系统设置", Description: "修改系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.users.view", Label: "查看用户", Description: "查看账号目录及获准资料", Group: "用户管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.users.create", Label: "创建用户", Description: "创建普通账号", Group: "用户管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.users.edit", Label: "编辑用户", Description: "编辑账号的非凭据资料", Group: "用户管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.users.disable", Label: "停用用户", Description: "停用或重新启用账号", Group: "用户管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.roles.view", Label: "查看角色", Description: "查看职位角色及绑定人数", Group: "角色管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.roles.manage", Label: "管理角色", Description: "创建、编辑和停用职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.roles.assign", Label: "分配角色", Description: "替换用户唯一生效职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.permissions.view", Label: "查看系统权限", Description: "查看系统授权、角色默认与上限", Group: "权限管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "system.permissions.manage", Label: "管理系统权限", Description: "配置明确系统授权与平台上限", Group: "权限管理", Scope: ScopeSystem, Protected: true},
|
||||
{ActionID: "project.create", Label: "创建项目", Description: "创建新的项目", Group: "项目入口", Scope: ScopeSystem},
|
||||
{ActionID: "project.settings", Label: "项目设置", Description: "修改本项目设置", Group: "项目管理", Scope: ScopeProject},
|
||||
{ActionID: "project.archive", Label: "归档项目", Description: "归档或恢复本人创建的项目", Group: "项目管理", Scope: ScopeProject},
|
||||
{ActionID: "project.stage.advance", Label: "推进项目阶段", Description: "推进本人创建项目的阶段", Group: "项目管理", Scope: ScopeProject},
|
||||
{ActionID: "project.members.manage", Label: "管理项目成员", Description: "维护本项目成员", Group: "项目管理", Scope: ScopeProject},
|
||||
{ActionID: "project.permissions.manage", Label: "管理项目权限", Description: "维护本项目成员权限覆盖", Group: "项目管理", Scope: ScopeProject},
|
||||
{ActionID: "document.duties.assign", Label: "分配文档三权", Description: "分配本项目文档编制、审核和批准职责", Group: "文档职责", Scope: ScopeProject},
|
||||
{ActionID: "document.read", Label: "读取文档", Description: "读取获准的项目文档", Group: "文档动作", Scope: ScopeProject},
|
||||
{ActionID: "document.author", Label: "编制文档", Description: "履行已分配的文档编制职责", Group: "文档动作", Scope: ScopeProject},
|
||||
{ActionID: "document.review", Label: "审核文档", Description: "履行已分配的文档审核职责", Group: "文档动作", Scope: ScopeProject},
|
||||
{ActionID: "document.approve", Label: "批准文档", Description: "履行已分配的文档批准职责", Group: "文档动作", Scope: ScopeProject},
|
||||
{ActionID: "document.publish", Label: "发布文档", Description: "发布已完成审签的项目文档", Group: "文档动作", Scope: ScopeProject},
|
||||
{ActionID: "trace.edit", Label: "编辑追溯网", Description: "编辑本项目追溯关系", Group: "项目内容", Scope: ScopeProject},
|
||||
{ActionID: "knowledge.publish", Label: "发布项目知识", Description: "发布或撤销本项目知识", Group: "项目内容", Scope: ScopeProject},
|
||||
{ActionID: "project.audit.read", Label: "读取业务审计", Description: "读取获准项目的业务审计", Group: "项目内容", Scope: ScopeProject},
|
||||
}
|
||||
|
||||
var descriptorByID = func() map[string]Descriptor {
|
||||
out := make(map[string]Descriptor, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
out[descriptor.ActionID] = descriptor
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func DescriptorByID(id string) (Descriptor, bool) {
|
||||
descriptor, ok := descriptorByID[id]
|
||||
return descriptor, ok
|
||||
}
|
||||
|
||||
func Catalog() []Descriptor {
|
||||
out := make([]Descriptor, len(descriptors))
|
||||
copy(out, descriptors)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) {
|
||||
if _, ok := DescriptorByID("invented.action"); ok {
|
||||
t.Fatal("unknown action registered")
|
||||
}
|
||||
got, ok := DescriptorByID("system.roles.manage")
|
||||
if !ok || got.Scope != ScopeSystem || !got.Protected {
|
||||
t.Fatalf("descriptor=%+v", got)
|
||||
}
|
||||
create, ok := DescriptorByID("project.create")
|
||||
if !ok || create.Scope != ScopeSystem || create.Protected {
|
||||
t.Fatalf("project.create=%+v", create)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogReturnsIndependentCopies(t *testing.T) {
|
||||
first := Catalog()
|
||||
if len(first) == 0 {
|
||||
t.Fatal("empty catalog")
|
||||
}
|
||||
first[0].Label = "mutated"
|
||||
second := Catalog()
|
||||
if second[0].Label == "mutated" {
|
||||
t.Fatal("Catalog exposed mutable registry storage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
type Descriptor = catalog.Descriptor
|
||||
type Scope = catalog.Scope
|
||||
|
||||
const (
|
||||
ScopeSystem = catalog.ScopeSystem
|
||||
ScopeProject = catalog.ScopeProject
|
||||
)
|
||||
|
||||
func Catalog() []Descriptor { return catalog.Catalog() }
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
ActionID string `json:"actionId"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func NewService(st *store.Store) *Service { return &Service{store: st} }
|
||||
|
||||
func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
|
||||
decision := Decision{ActionID: actionID}
|
||||
descriptor, ok := catalog.DescriptorByID(actionID)
|
||||
if !ok || descriptor.Scope != catalog.ScopeSystem || s == nil || s.store == nil {
|
||||
decision.Reason = "unknown-system-action"
|
||||
return decision
|
||||
}
|
||||
epoch, err := s.store.AuthorityEpoch()
|
||||
if err != nil {
|
||||
decision.Reason = "authority-unavailable"
|
||||
return decision
|
||||
}
|
||||
decision.AuthorityEpoch = epoch
|
||||
account, err := s.store.GetAccountByOwner(actor)
|
||||
if err != nil || !account.Enabled {
|
||||
decision.Reason = "account-unavailable"
|
||||
return decision
|
||||
}
|
||||
// is_admin remains the narrow bootstrap/root recovery source. Ordinary
|
||||
// delegated management uses explicit system_grants.
|
||||
if account.IsAdmin {
|
||||
decision.Allowed, decision.Source = true, "bootstrap-admin"
|
||||
return decision
|
||||
}
|
||||
allowed, err := s.store.SystemGrantAllowed(actor, actionID)
|
||||
if err != nil {
|
||||
decision.Reason = "authority-unavailable"
|
||||
return decision
|
||||
}
|
||||
if allowed {
|
||||
decision.Allowed, decision.Source = true, "system-grant"
|
||||
return decision
|
||||
}
|
||||
if actionID == "project.create" {
|
||||
roleAllowed, capAllowed, err := s.store.RolePermissionAllowed(actor, actionID)
|
||||
if err != nil {
|
||||
decision.Reason = "authority-unavailable"
|
||||
return decision
|
||||
}
|
||||
if roleAllowed && capAllowed {
|
||||
decision.Allowed, decision.Source = true, "role"
|
||||
return decision
|
||||
}
|
||||
}
|
||||
decision.Reason = "not-granted"
|
||||
return decision
|
||||
}
|
||||
|
||||
func (s *Service) EffectiveSystemActions(actor string) []string {
|
||||
actions := []string{}
|
||||
for _, descriptor := range catalog.Catalog() {
|
||||
if descriptor.Scope != catalog.ScopeSystem {
|
||||
continue
|
||||
}
|
||||
if decision := s.AuthorizeSystem(actor, descriptor.ActionID); decision.Allowed {
|
||||
actions = append(actions, descriptor.ActionID)
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
actor, ok := s.requireSystemAction(w, r, "system.users.view")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) {
|
||||
if err := st.ClearMustChangePassword("actor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "actor")
|
||||
key, err := st.CreateAPIKey("actor", "request")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b
|
||||
}
|
||||
|
||||
func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
|
||||
return
|
||||
}
|
||||
users, err := s.st.ListUsersAdmin()
|
||||
@@ -319,7 +319,7 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
actor, ok := s.requireSystemAction(w, r, "system.users.create")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -426,7 +426,7 @@ func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
|
||||
return
|
||||
}
|
||||
account, err := s.st.GetAccount(r.PathValue("userId"))
|
||||
@@ -438,7 +438,7 @@ func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
actor, ok := s.requireSystemAction(w, r, "system.users.edit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -463,7 +463,7 @@ func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, act
|
||||
}
|
||||
|
||||
func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
actor, ok := s.requireSystemAction(w, r, "system.users.disable")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,3 +15,22 @@ func createLegacyAPIKeyFixture(t *testing.T, st *store.Store, owner, name string
|
||||
}
|
||||
return st.CreateAPIKey(owner, name)
|
||||
}
|
||||
|
||||
func grantProjectCreateFixture(t *testing.T, st *store.Store, owner string) {
|
||||
t.Helper()
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err := st.CreateRole(owner, store.RoleCreate{Name: "项目创建测试职位-" + owner})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions(owner, role.RoleID, role.RoleVersion,
|
||||
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "fixture-policy-"+owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole(owner, account.ID, role.RoleID, account.StateVersion, "fixture-binding-"+owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ func TestTenantIsolationHTTP(t *testing.T) {
|
||||
h := srv.Handler()
|
||||
keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a")
|
||||
keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
|
||||
do := func(key, method, path string, body any) int {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestUploadPreIndexSwitch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON(t, map[string]string{"name": "EXT9"})))
|
||||
req.Header.Set("Authorization", "Bearer "+alice)
|
||||
|
||||
@@ -33,6 +33,7 @@ func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *
|
||||
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
calls := []string{}
|
||||
@@ -205,6 +206,7 @@ func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bearerRequest(h http.Handler, method, path, body, key string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("engineer", "Engineer", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engineer, _ := st.GetAccountByOwner("engineer")
|
||||
key, err := st.CreateAPIKey("engineer", "engineer-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"未授权项目"}`, key); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("project created without permission: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"软件工程师"}`, admin)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeRoleHTTP(t, roleRec)
|
||||
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"project-create-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if policy.Code != http.StatusOK {
|
||||
t.Fatalf("set role permission: %d %s", policy.Code, policy.Body.String())
|
||||
}
|
||||
assign := do(h, "PUT", "/api/admin/users/"+engineer.ID+"/role",
|
||||
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(engineer.StateVersion, 10)+`,"idempotencyKey":"assign-engineer"}`, admin)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign engineer: %d %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"授权项目"}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("role project create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("viewer", "Viewer", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
viewer, _ := st.GetAccountByOwner("viewer")
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
grant := do(h, "PUT", "/api/admin/users/"+viewer.ID+"/system-grants",
|
||||
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"viewer-grant","actions":["system.users.view"]}`, admin)
|
||||
if grant.Code != http.StatusOK {
|
||||
t.Fatalf("grant viewer: %d %s", grant.Code, grant.Body.String())
|
||||
}
|
||||
key, err := st.CreateAPIKey("viewer", "viewer-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/admin/users", "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("users.view denied: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/admin/users", `{"owner":"forbidden-user"}`, key); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("users.view created account: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/admin/roles", "", key); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("users.view read roles: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/me", "", key); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"systemActions":["system.users.view"]`) {
|
||||
t.Fatalf("me system actions: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/authz/explain?action=system.users.view", "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
|
||||
} else {
|
||||
var decision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil || !decision.Allowed || decision.Source != "system-grant" {
|
||||
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
authzsvc "agent-system-backend/internal/authz"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) requireSystemAction(w http.ResponseWriter, r *http.Request, actionID string) (string, bool) {
|
||||
actor := s.owner(r)
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
return actor, true
|
||||
}
|
||||
decision := s.authorizer.AuthorizeSystem(actor, actionID)
|
||||
if !decision.Allowed {
|
||||
http.Error(w, "system action denied: "+decision.Reason, http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
return actor, true
|
||||
}
|
||||
|
||||
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"permissions": authzsvc.Catalog()})
|
||||
}
|
||||
|
||||
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
roles, next, err := s.st.ListRoles(r.URL.Query().Get("query"), r.URL.Query().Get("mode"),
|
||||
r.URL.Query().Get("status"), r.URL.Query().Get("action"), r.URL.Query().Get("cursor"), limit)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"roles": roles, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAction(w, r, "system.roles.manage")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, "invalid role", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
role, err := s.st.CreateRole(actor, store.RoleCreate{Name: req.Name, Description: req.Description})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, store.RoleAdminView{RoleRecord: role, Permissions: []store.RolePermissionRecord{}})
|
||||
}
|
||||
|
||||
func (s *Server) getRole(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
|
||||
return
|
||||
}
|
||||
role, err := s.st.RoleAdmin(r.PathValue("roleId"))
|
||||
writeJSONOr(w, role, err)
|
||||
}
|
||||
|
||||
func (s *Server) patchRole(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAction(w, r, "system.roles.manage")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedRoleVersion < 1 {
|
||||
http.Error(w, "invalid role patch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
role, err := s.st.PatchRole(actor, r.PathValue("roleId"), req.ExpectedRoleVersion,
|
||||
store.RolePatch{Name: req.Name, Description: req.Description, Enabled: req.Enabled})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
view, err := s.st.RoleAdmin(role.RoleID)
|
||||
writeJSONOr(w, view, err)
|
||||
}
|
||||
|
||||
func (s *Server) replaceRolePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Changes []store.RolePermissionChange `json:"changes"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
|
||||
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
receipt, err := s.st.ReplaceRolePermissions(actor, r.PathValue("roleId"), req.ExpectedRoleVersion, req.Changes, req.IdempotencyKey)
|
||||
writeJSONOr(w, receipt, err)
|
||||
}
|
||||
|
||||
func (s *Server) getUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
|
||||
return
|
||||
}
|
||||
view, err := s.st.RoleForUser(r.PathValue("userId"))
|
||||
writeJSONOr(w, view, err)
|
||||
}
|
||||
|
||||
func (s *Server) assignUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RoleID string `json:"roleId"`
|
||||
ExpectedStateVersion int64 `json:"expectedStateVersion"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.RoleID == "" || req.ExpectedStateVersion < 1 || req.IdempotencyKey == "" {
|
||||
http.Error(w, "invalid role assignment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
binding, err := s.st.AssignUserRole(actor, r.PathValue("userId"), req.RoleID, req.ExpectedStateVersion, req.IdempotencyKey)
|
||||
writeJSONOr(w, binding, err)
|
||||
}
|
||||
|
||||
func (s *Server) getSystemGrants(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
|
||||
return
|
||||
}
|
||||
actions, epoch, err := s.st.SystemGrantsForUser(r.PathValue("userId"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"actions": actions, "authorityEpoch": epoch})
|
||||
}
|
||||
|
||||
func (s *Server) replaceSystemGrants(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedAuthorityEpoch < 1 || req.IdempotencyKey == "" {
|
||||
http.Error(w, "invalid system grant batch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
receipt, err := s.st.ReplaceSystemGrants(actor, r.PathValue("userId"), req.ExpectedAuthorityEpoch, req.Actions, req.IdempotencyKey)
|
||||
writeJSONOr(w, receipt, err)
|
||||
}
|
||||
|
||||
func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
actionID := r.URL.Query().Get("action")
|
||||
decision := s.authorizer.AuthorizeSystem(s.owner(r), actionID)
|
||||
if actionID == "" {
|
||||
http.Error(w, "action required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, decision)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roleHTTPView struct {
|
||||
RoleID string `json:"roleId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
BindingCount int `json:"bindingCount"`
|
||||
}
|
||||
|
||||
func decodeRoleHTTP(t *testing.T, rec *httptest.ResponseRecorder) roleHTTPView {
|
||||
t.Helper()
|
||||
var role roleHTTPView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &role); err != nil {
|
||||
t.Fatalf("decode role: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
|
||||
if rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "system.roles.manage") {
|
||||
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
createdRec := do(h, "POST", "/api/admin/roles", `{"name":"软件工程师","description":"负责软件研发"}`, admin)
|
||||
if createdRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role: %d %s", createdRec.Code, createdRec.Body.String())
|
||||
}
|
||||
created := decodeRoleHTTP(t, createdRec)
|
||||
if created.RoleID == "" || created.Description != "负责软件研发" || created.RoleVersion != 1 {
|
||||
t.Fatalf("created=%+v", created)
|
||||
}
|
||||
|
||||
list := do(h, "GET", "/api/admin/roles?query=软件工程师&mode=exact&status=enabled", "", admin)
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), created.RoleID) {
|
||||
t.Fatalf("list role: %d %s", list.Code, list.Body.String())
|
||||
}
|
||||
patchedRec := do(h, "PATCH", "/api/admin/roles/"+created.RoleID,
|
||||
`{"expectedRoleVersion":1,"description":"负责产品软件研发"}`, admin)
|
||||
if patchedRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch role: %d %s", patchedRec.Code, patchedRec.Body.String())
|
||||
}
|
||||
patched := decodeRoleHTTP(t, patchedRec)
|
||||
if patched.RoleID != created.RoleID || patched.Description != "负责产品软件研发" || patched.RoleVersion != 2 {
|
||||
t.Fatalf("patched=%+v", patched)
|
||||
}
|
||||
permissions := do(h, "PUT", "/api/admin/roles/"+created.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":2,"idempotencyKey":"role-policy-1","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if permissions.Code != http.StatusOK {
|
||||
t.Fatalf("permissions: %d %s", permissions.Code, permissions.Body.String())
|
||||
}
|
||||
|
||||
userRec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"Alice"}}`, admin)
|
||||
if userRec.Code != http.StatusOK {
|
||||
t.Fatalf("create user: %d %s", userRec.Code, userRec.Body.String())
|
||||
}
|
||||
var user struct {
|
||||
UserID string `json:"userId"`
|
||||
StateVersion int64 `json:"stateVersion"`
|
||||
}
|
||||
if err := json.Unmarshal(userRec.Body.Bytes(), &user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assign := do(h, "PUT", "/api/admin/users/"+user.UserID+"/role",
|
||||
`{"roleId":"`+created.RoleID+`","expectedStateVersion":`+"1"+`,"idempotencyKey":"assign-alice"}`, admin)
|
||||
if assign.Code != http.StatusOK || !strings.Contains(assign.Body.String(), created.RoleID) {
|
||||
t.Fatalf("assign: %d %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
get := do(h, "GET", "/api/admin/users/"+user.UserID+"/role", "", admin)
|
||||
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), created.RoleID) {
|
||||
t.Fatalf("get binding: %d %s", get.Code, get.Body.String())
|
||||
}
|
||||
list = do(h, "GET", "/api/admin/roles?query=软件&mode=fuzzy&status=all", "", admin)
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"bindingCount":1`) {
|
||||
t.Fatalf("binding count: %d %s", list.Code, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleHTTPUsesSpecificSystemActions(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("role-manager", "Role Manager", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := st.GetAccountByOwner("role-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-role-manager","actions":["system.roles.view","system.roles.manage"]}`, admin)
|
||||
if grant.Code != http.StatusOK {
|
||||
t.Fatalf("grant: %d %s", grant.Code, grant.Body.String())
|
||||
}
|
||||
key, err := st.CreateAPIKey("role-manager", "role-manager-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/admin/roles", strings.NewReader(`{"name":"测试职位"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("role manager create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
role := decodeRoleHTTP(t, rec)
|
||||
req = httptest.NewRequest("PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
strings.NewReader(`{"expectedRoleVersion":1,"idempotencyKey":"forbidden-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("role manager changed permissions: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
authzsvc "agent-system-backend/internal/authz"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/execenv"
|
||||
@@ -43,6 +44,7 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
authorizer *authzsvc.Service
|
||||
accountSearchKey store.AccountCursorKey
|
||||
accountSearchKeyErr error
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
@@ -142,9 +144,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
|
||||
}
|
||||
@@ -166,7 +172,7 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.
|
||||
panic(err) // embed 布局错误属构建期问题
|
||||
}
|
||||
key, keyErr := st.GetAccountCursorKey()
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub,
|
||||
return &Server{st: st, authorizer: authzsvc.NewService(st), mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub,
|
||||
accountSearchKey: key, accountSearchKeyErr: keyErr}
|
||||
}
|
||||
|
||||
@@ -318,6 +324,17 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey)
|
||||
mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys)
|
||||
mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey)
|
||||
mux.HandleFunc("GET /api/admin/permissions/catalog", s.listPermissionCatalog)
|
||||
mux.HandleFunc("GET /api/admin/roles", s.listRoles)
|
||||
mux.HandleFunc("POST /api/admin/roles", s.createRole)
|
||||
mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole)
|
||||
mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole)
|
||||
mux.HandleFunc("PUT /api/admin/roles/{roleId}/permissions", s.replaceRolePermissions)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}/role", s.getUserRole)
|
||||
mux.HandleFunc("PUT /api/admin/users/{userId}/role", s.assignUserRole)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
|
||||
mux.HandleFunc("PUT /api/admin/users/{userId}/system-grants", s.replaceSystemGrants)
|
||||
mux.HandleFunc("GET /api/authz/explain", s.explainSystemAuthorization)
|
||||
|
||||
// ---- 项目 / 会话 ----
|
||||
mux.HandleFunc("GET /api/projects", s.listProjects)
|
||||
@@ -721,6 +738,9 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "project.create"); !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -878,6 +898,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
out["passwordSet"] = st.HasPassword
|
||||
out["systemActions"] = s.authorizer.EffectiveSystemActions(owner)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -2253,8 +2274,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 {
|
||||
@@ -2359,6 +2384,14 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrLastPermissionManager) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestSharedKnowledgeRetrieval(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -98,6 +99,7 @@ func TestProjectSSEScopeGate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -151,6 +153,7 @@ func TestSharedCitationRefDownloadable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -319,8 +327,8 @@ func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile Acc
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,11 @@ func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
|
||||
if err := insertAccount(s, tx, w); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.IsAdmin {
|
||||
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.PasswordHash == "" && w.Purpose != "" {
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
@@ -145,6 +150,9 @@ func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error)
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1196,6 +1196,17 @@ func init() {
|
||||
"sqlite": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
|
||||
}},
|
||||
migration{version: 49, name: "role_policy_authority",
|
||||
up: map[string][]string{
|
||||
"sqlite": rolePolicyFoundationUp("INTEGER"),
|
||||
"postgres": rolePolicyFoundationUp("BOOLEAN"),
|
||||
},
|
||||
down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown},
|
||||
},
|
||||
migration{version: 50, 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`}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1230,6 +1241,75 @@ var execEnvDown = []string{
|
||||
`DROP TABLE IF EXISTS exec_env_requests`,
|
||||
}
|
||||
|
||||
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 (
|
||||
role_id TEXT PRIMARY KEY,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',enabled ` + boolType + ` NOT NULL,
|
||||
role_version BIGINT NOT NULL DEFAULT 1,created_by TEXT NOT NULL,updated_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id),FOREIGN KEY(updated_by) REFERENCES users(id))`,
|
||||
`CREATE TABLE role_permissions (
|
||||
role_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL,
|
||||
cap_allowed ` + boolType + ` NOT NULL,permission_version BIGINT NOT NULL DEFAULT 1,
|
||||
updated_by TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(role_id,action_id),
|
||||
FOREIGN KEY(role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(updated_by) REFERENCES users(id))`,
|
||||
`CREATE TABLE user_role_bindings (
|
||||
binding_id TEXT PRIMARY KEY,user_id TEXT NOT NULL,role_id TEXT NOT NULL,
|
||||
active ` + boolType + ` NOT NULL,binding_version BIGINT NOT NULL DEFAULT 1,
|
||||
assigned_by TEXT NOT NULL,assigned_at TEXT NOT NULL,ended_at TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),FOREIGN KEY(role_id) REFERENCES roles(role_id),
|
||||
FOREIGN KEY(assigned_by) REFERENCES users(id))`,
|
||||
`CREATE UNIQUE INDEX uq_user_role_bindings_active ON user_role_bindings(user_id) WHERE active=TRUE`,
|
||||
`CREATE INDEX idx_user_role_bindings_role ON user_role_bindings(role_id,active)`,
|
||||
`CREATE TABLE system_grants (
|
||||
user_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL,
|
||||
grant_version BIGINT NOT NULL DEFAULT 1,updated_by TEXT NOT NULL,updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id,action_id),FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(updated_by) REFERENCES users(id))`,
|
||||
`CREATE TABLE authority_state (
|
||||
id INTEGER PRIMARY KEY,authority_epoch BIGINT NOT NULL,catalog_version BIGINT NOT NULL,
|
||||
updated_at TEXT NOT NULL)`,
|
||||
`INSERT INTO authority_state(id,authority_epoch,catalog_version,updated_at) VALUES(1,1,1,'')`,
|
||||
`CREATE TABLE policy_receipts (
|
||||
receipt_id TEXT PRIMARY KEY,actor_user_id TEXT NOT NULL,idempotency_key TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,request_hash TEXT NOT NULL,result_json TEXT NOT NULL,created_at TEXT NOT NULL,
|
||||
UNIQUE(actor_user_id,idempotency_key),FOREIGN KEY(actor_user_id) REFERENCES users(id))`,
|
||||
}
|
||||
for _, actionID := range rolePolicyAdminActionsV49 {
|
||||
statements = append(statements, `INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
|
||||
SELECT id,'`+actionID+`',TRUE,1,id,created_at FROM users WHERE is_admin=TRUE`)
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
var rolePolicyAdminActionsV49 = []string{
|
||||
"system.settings.read", "system.settings.write",
|
||||
"system.users.view", "system.users.create", "system.users.edit", "system.users.disable",
|
||||
"system.roles.view", "system.roles.manage", "system.roles.assign",
|
||||
"system.permissions.view", "system.permissions.manage", "project.create",
|
||||
}
|
||||
|
||||
var rolePolicyFoundationDown = []string{
|
||||
`DROP TABLE policy_receipts`,
|
||||
`DROP TABLE authority_state`,
|
||||
`DROP TABLE system_grants`,
|
||||
`DROP INDEX idx_user_role_bindings_role`,
|
||||
`DROP INDEX uq_user_role_bindings_active`,
|
||||
`DROP TABLE user_role_bindings`,
|
||||
`DROP TABLE role_permissions`,
|
||||
`DROP TABLE roles`,
|
||||
}
|
||||
|
||||
var recoveryFoundationUp = []string{
|
||||
`ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
|
||||
`ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`,
|
||||
|
||||
@@ -55,6 +55,8 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
46: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
|
||||
47: false, // human_decision_proofs:独立真人确认表,旧读者不读取
|
||||
48: false, // document_render_deliveries:新增普通工作区文档交付回执表
|
||||
49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
|
||||
50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -41,6 +41,8 @@ func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) {
|
||||
{46, "project_work_observation_outbox"},
|
||||
{47, "human_decision_proofs"},
|
||||
{48, "document_render_deliveries"},
|
||||
{49, "role_policy_authority"},
|
||||
{50, "role_authority_import_stage"},
|
||||
}
|
||||
for _, item := range want {
|
||||
if got, ok := MigrationName(item.version); !ok || got != item.name {
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRolePolicy = errors.New("store: invalid role policy")
|
||||
ErrLastPermissionManager = errors.New("store: final permission manager")
|
||||
)
|
||||
|
||||
type RoleCreate struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type RolePatch struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type RolePermissionChange struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
}
|
||||
|
||||
type RolePermissionRecord struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
PermissionVersion int64 `json:"permissionVersion"`
|
||||
}
|
||||
|
||||
type RoleAdminView struct {
|
||||
RoleRecord
|
||||
BindingCount int `json:"bindingCount"`
|
||||
Permissions []RolePermissionRecord `json:"permissions"`
|
||||
}
|
||||
|
||||
type PolicyReceipt struct {
|
||||
ReceiptID string `json:"receiptId"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
RoleVersion int64 `json:"roleVersion,omitempty"`
|
||||
}
|
||||
|
||||
// RoleRecord is the stable position definition stored by Backend.
|
||||
type RoleRecord struct {
|
||||
RoleID string `json:"roleId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RoleBinding records one appointment. At most one active binding exists for
|
||||
// a user; inactive rows retain the appointment history.
|
||||
type RoleBinding struct {
|
||||
BindingID string `json:"bindingId"`
|
||||
UserID string `json:"userId"`
|
||||
RoleID string `json:"roleId"`
|
||||
Active bool `json:"active"`
|
||||
BindingVersion int64 `json:"bindingVersion"`
|
||||
AssignedBy string `json:"assignedBy"`
|
||||
AssignedAt time.Time `json:"assignedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
UserStateVersion int64 `json:"userStateVersion"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
}
|
||||
|
||||
type RoleBindingView struct {
|
||||
Binding *RoleBinding `json:"binding,omitempty"`
|
||||
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))
|
||||
}
|
||||
|
||||
func (s *Store) seedSystemAdminGrantsTx(tx *sql.Tx, owner string) error {
|
||||
var version sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if !version.Valid || version.Int64 < 49 {
|
||||
return nil
|
||||
}
|
||||
var userID string
|
||||
if err := tx.QueryRow(s.q(`SELECT id FROM users WHERE owner=?`), owner).Scan(&userID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowISO()
|
||||
for _, actionID := range rolePolicyAdminActionsV49 {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,action_id) DO UPDATE SET allowed=excluded.allowed,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
|
||||
userID, actionID, true, 1, userID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoleText(name, description string) (string, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
description = strings.TrimSpace(description)
|
||||
if name == "" || len([]rune(name)) > 128 || len([]rune(description)) > 2000 {
|
||||
return "", "", ErrInvalidRolePolicy
|
||||
}
|
||||
return name, description, nil
|
||||
}
|
||||
|
||||
func (s *Store) policyWrite(actor, targetUserID string, fn func(*sql.Tx, Account, Account, int64) error) error {
|
||||
if actor == "" || fn == nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
targetOwner := actor
|
||||
if targetUserID != "" {
|
||||
target, err := s.GetAccount(targetUserID)
|
||||
if err != nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
targetOwner = target.Owner
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, actor, targetOwner)
|
||||
if err != nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
actorAccount := accounts[actor]
|
||||
targetAccount := accounts[targetOwner]
|
||||
if !actorAccount.Enabled || !targetAccount.Enabled || (targetUserID != "" && targetAccount.ID != targetUserID) {
|
||||
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 err := fn(tx, actorAccount, targetAccount, epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func scanRole(row *sql.Row) (RoleRecord, error) {
|
||||
var role RoleRecord
|
||||
var createdAt, updatedAt string
|
||||
err := row.Scan(&role.RoleID, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion,
|
||||
&role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RoleRecord{}, ErrNotFound
|
||||
}
|
||||
return RoleRecord{}, err
|
||||
}
|
||||
role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
|
||||
role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *Store) roleTx(tx *sql.Tx, roleID string, lock bool) (RoleRecord, error) {
|
||||
query := `SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?`
|
||||
if lock && s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
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 {
|
||||
return RoleRecord{}, err
|
||||
}
|
||||
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)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, name, normalizeRoleName(name), description, true, 1, actorAccount.ID, actorAccount.ID, now, now)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
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 err
|
||||
}
|
||||
out, err = s.roleTx(tx, roleID, false)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if current.RoleVersion != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
name, description, enabled := current.Name, current.Description, current.Enabled
|
||||
if patch.Name != nil {
|
||||
name = *patch.Name
|
||||
}
|
||||
if patch.Description != nil {
|
||||
description = *patch.Description
|
||||
}
|
||||
if patch.Enabled != nil {
|
||||
enabled = *patch.Enabled
|
||||
}
|
||||
name, description, err = validateRoleText(name, description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowISO()
|
||||
result, err := tx.Exec(s.q(`UPDATE roles SET name=?,normalized_name=?,description=?,enabled=?,role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`),
|
||||
name, normalizeRoleName(name), description, enabled, actorAccount.ID, now, roleID, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
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 err
|
||||
}
|
||||
out, err = s.roleTx(tx, roleID, false)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func policyRequestHash(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) replayReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, out any) (bool, error) {
|
||||
if key == "" {
|
||||
return false, ErrInvalidRolePolicy
|
||||
}
|
||||
var storedOperation, storedHash, result string
|
||||
err := tx.QueryRow(s.q(`SELECT operation,request_hash,result_json FROM policy_receipts WHERE actor_user_id=? AND idempotency_key=?`), actorID, key).
|
||||
Scan(&storedOperation, &storedHash, &result)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if storedOperation != operation || storedHash != requestHash {
|
||||
return false, ErrConflict
|
||||
}
|
||||
return true, json.Unmarshal([]byte(result), out)
|
||||
}
|
||||
|
||||
func (s *Store) saveReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO policy_receipts(receipt_id,actor_user_id,idempotency_key,operation,request_hash,result_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`), receiptID(value), actorID, key, operation, requestHash, string(data), nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
func receiptID(value any) string {
|
||||
if receipt, ok := value.(PolicyReceipt); ok && receipt.ReceiptID != "" {
|
||||
return receipt.ReceiptID
|
||||
}
|
||||
if binding, ok := value.(RoleBinding); ok && binding.BindingID != "" {
|
||||
return "receipt_" + binding.BindingID
|
||||
}
|
||||
return newID("receipt")
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, changes []RolePermissionChange, key string) (PolicyReceipt, error) {
|
||||
requestHash := policyRequestHash(struct {
|
||||
RoleID string
|
||||
Expected int64
|
||||
Changes []RolePermissionChange
|
||||
}{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
|
||||
}
|
||||
role, err := s.roleTx(tx, roleID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role.RoleVersion != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, change := range changes {
|
||||
descriptor, ok := catalog.DescriptorByID(change.ActionID)
|
||||
if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
seen[change.ActionID] = true
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=?`), roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowISO()
|
||||
for _, change := range changes {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := tx.Exec(s.q(`UPDATE roles SET role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), actorAccount.ID, now, roleID, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
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 err
|
||||
}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1}
|
||||
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64, key string) (RoleBinding, error) {
|
||||
requestHash := policyRequestHash(struct {
|
||||
UserID, RoleID string
|
||||
Expected 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
|
||||
}
|
||||
if target.StateVersion != expectedState {
|
||||
return ErrConflict
|
||||
}
|
||||
role, err := s.roleTx(tx, roleID, true)
|
||||
if err != nil || !role.Enabled {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
now := nowISO()
|
||||
if _, err := tx.Exec(s.q(`UPDATE user_role_bindings SET active=?,binding_version=binding_version+1,ended_at=? WHERE user_id=? AND active=?`), false, now, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
bindingID := newID("binding")
|
||||
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(?,?,?,?,?,?,?,?)`), bindingID, userID, roleID, true, 1, actorAccount.ID, now, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`), userID, expectedState, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
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 err
|
||||
}
|
||||
assignedAt, _ := time.Parse(time.RFC3339Nano, now)
|
||||
out = RoleBinding{BindingID: bindingID, UserID: userID, RoleID: roleID, Active: true, BindingVersion: 1,
|
||||
AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: expectedState + 1, AuthorityEpoch: epoch + 1}
|
||||
return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) RoleForUser(userID string) (RoleBindingView, error) {
|
||||
row := s.db.QueryRow(s.q(`SELECT b.binding_id,b.user_id,b.role_id,b.active,b.binding_version,b.assigned_by,b.assigned_at,b.ended_at,
|
||||
r.name,r.description,r.enabled,r.role_version,r.created_by,r.updated_by,r.created_at,r.updated_at,u.state_version,a.authority_epoch
|
||||
FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id JOIN users u ON u.id=b.user_id CROSS JOIN authority_state a
|
||||
WHERE b.user_id=? AND b.active=? AND a.id=1`), userID, true)
|
||||
var binding RoleBinding
|
||||
var role RoleRecord
|
||||
var assignedAt, endedAt, createdAt, updatedAt string
|
||||
err := row.Scan(&binding.BindingID, &binding.UserID, &binding.RoleID, &binding.Active, &binding.BindingVersion,
|
||||
&binding.AssignedBy, &assignedAt, &endedAt, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion,
|
||||
&role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt, &binding.UserStateVersion, &binding.AuthorityEpoch)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RoleBindingView{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RoleBindingView{}, err
|
||||
}
|
||||
role.RoleID = binding.RoleID
|
||||
binding.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt)
|
||||
if endedAt != "" {
|
||||
parsed, _ := time.Parse(time.RFC3339Nano, endedAt)
|
||||
binding.EndedAt = &parsed
|
||||
}
|
||||
role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
|
||||
role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
|
||||
return RoleBindingView{Binding: &binding, Role: &role}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AuthorityEpoch() (int64, error) {
|
||||
var epoch int64
|
||||
err := s.db.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&epoch)
|
||||
return epoch, err
|
||||
}
|
||||
|
||||
func (s *Store) GetRole(roleID string) (RoleRecord, error) {
|
||||
return scanRole(s.db.QueryRow(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?`), roleID))
|
||||
}
|
||||
|
||||
func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) {
|
||||
role, err := s.GetRole(roleID)
|
||||
if err != nil {
|
||||
return RoleAdminView{}, err
|
||||
}
|
||||
view := RoleAdminView{RoleRecord: role}
|
||||
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), roleID, true).Scan(&view.BindingCount); err != nil {
|
||||
return RoleAdminView{}, err
|
||||
}
|
||||
view.Permissions, err = s.RolePermissions(roleID)
|
||||
return view, err
|
||||
}
|
||||
|
||||
func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []RolePermissionRecord{}
|
||||
for rows.Next() {
|
||||
var permission RolePermissionRecord
|
||||
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, permission)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
where := []string{"role_id>?"}
|
||||
args := []any{cursor}
|
||||
normalized := normalizeRoleName(query)
|
||||
if normalized != "" {
|
||||
switch mode {
|
||||
case "exact":
|
||||
where = append(where, "normalized_name=?")
|
||||
args = append(args, normalized)
|
||||
case "fuzzy", "":
|
||||
where = append(where, "(LOWER(name) LIKE ? OR LOWER(description) LIKE ?)")
|
||||
args = append(args, "%"+normalized+"%", "%"+normalized+"%")
|
||||
default:
|
||||
return nil, "", ErrInvalidRolePolicy
|
||||
}
|
||||
}
|
||||
switch status {
|
||||
case "enabled":
|
||||
where = append(where, "enabled=?")
|
||||
args = append(args, true)
|
||||
case "disabled":
|
||||
where = append(where, "enabled=?")
|
||||
args = append(args, false)
|
||||
case "", "all":
|
||||
default:
|
||||
return nil, "", ErrInvalidRolePolicy
|
||||
}
|
||||
if actionID != "" {
|
||||
if _, ok := catalog.DescriptorByID(actionID); !ok {
|
||||
return nil, "", ErrInvalidRolePolicy
|
||||
}
|
||||
where = append(where, "EXISTS(SELECT 1 FROM role_permissions p WHERE p.role_id=roles.role_id AND p.action_id=?)")
|
||||
args = append(args, actionID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
rows, err := s.db.Query(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at,
|
||||
(SELECT COUNT(*) FROM user_role_bindings b WHERE b.role_id=roles.role_id AND b.active=TRUE)
|
||||
FROM roles WHERE `+strings.Join(where, " AND ")+` ORDER BY role_id LIMIT ?`), args...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []RoleAdminView{}
|
||||
for rows.Next() {
|
||||
var view RoleAdminView
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&view.RoleID, &view.Name, &view.Description, &view.Enabled, &view.RoleVersion,
|
||||
&view.CreatedBy, &view.UpdatedBy, &createdAt, &updatedAt, &view.BindingCount); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
view.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
|
||||
view.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
|
||||
out = append(out, view)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
next := ""
|
||||
if len(out) > limit {
|
||||
next = out[limit-1].RoleID
|
||||
out = out[:limit]
|
||||
}
|
||||
for i := range out {
|
||||
permissions, err := s.RolePermissions(out[i].RoleID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out[i].Permissions = permissions
|
||||
}
|
||||
return out, next, nil
|
||||
}
|
||||
|
||||
func (s *Store) SystemGrantsForUser(userID string) ([]string, int64, error) {
|
||||
if _, err := s.GetAccount(userID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.db.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=? ORDER BY action_id`), userID, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
actions := []string{}
|
||||
for rows.Next() {
|
||||
var actionID string
|
||||
if err := rows.Scan(&actionID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
actions = append(actions, actionID)
|
||||
}
|
||||
epoch, err := s.AuthorityEpoch()
|
||||
return actions, epoch, err
|
||||
}
|
||||
|
||||
func (s *Store) SystemGrantAllowed(owner, actionID string) (bool, error) {
|
||||
var allowed bool
|
||||
err := s.db.QueryRow(s.q(`SELECT g.allowed FROM system_grants g JOIN users u ON u.id=g.user_id
|
||||
WHERE u.owner=? AND u.enabled=? AND g.action_id=?`), owner, true, actionID).Scan(&allowed)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
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
|
||||
JOIN user_role_bindings b ON b.user_id=u.id AND b.active=TRUE
|
||||
JOIN roles r ON r.role_id=b.role_id AND r.enabled=TRUE
|
||||
JOIN role_permissions p ON p.role_id=r.role_id AND p.action_id=?
|
||||
WHERE u.owner=? AND u.enabled=TRUE`), actionID, owner).Scan(&allowed, &capAllowed)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, false, nil
|
||||
}
|
||||
return allowed, capAllowed, err
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, actions []string, key string) (PolicyReceipt, error) {
|
||||
requestHash := policyRequestHash(struct {
|
||||
UserID string
|
||||
Epoch int64
|
||||
Actions []string
|
||||
}{userID, expectedEpoch, actions})
|
||||
var out PolicyReceipt
|
||||
err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error {
|
||||
replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, &out)
|
||||
if err != nil || replayed {
|
||||
return err
|
||||
}
|
||||
if epoch != expectedEpoch {
|
||||
return ErrConflict
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, actionID := range actions {
|
||||
descriptor, ok := catalog.DescriptorByID(actionID)
|
||||
if !ok || descriptor.Scope != catalog.ScopeSystem || seen[actionID] {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
seen[actionID] = true
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowISO()
|
||||
for _, actionID := range actions {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?)`), userID, actionID, true, 1, actorAccount.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 err
|
||||
}
|
||||
if managers == 0 {
|
||||
return ErrLastPermissionManager
|
||||
}
|
||||
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 err
|
||||
}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1}
|
||||
return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out)
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRolePolicyMigrationSupportsOneRoleForManyUsers(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var aliceID, bobID string
|
||||
if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='alice'`).Scan(&aliceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='bob'`).Scan(&bobID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := nowISO()
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`), "role_engineer", "软件工程师", "软件工程师", "负责软件研发", true, 1, aliceID, aliceID, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, binding := range []struct{ id, userID string }{{"bind_alice", aliceID}, {"bind_bob", bobID}} {
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`), binding.id, binding.userID, "role_engineer", true, 1, aliceID, now, ""); err != nil {
|
||||
t.Fatalf("assign shared role to %s: %v", binding.userID, err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id='role_engineer' AND active=TRUE`).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("active role bindings=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePolicyMigrationRejectsTwoActiveRolesForOneUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var userID string
|
||||
if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='alice'`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := nowISO()
|
||||
for _, roleID := range []string{"role_engineer", "role_reviewer"} {
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, roleID, roleID, "", true, 1, userID, userID, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`), "bind_1", userID, "role_engineer", true, 1, userID, now, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`), "bind_2", userID, "role_reviewer", true, 1, userID, now, "")
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
t.Fatalf("second active role must hit unique constraint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.MigrateDown(48); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err == nil {
|
||||
t.Fatal("roles table remained after v49 rollback")
|
||||
}
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil {
|
||||
t.Fatalf("roles table missing after reapply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleAuthorityImportStageUpgradesExistingV49(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.MigrateDown(49); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil {
|
||||
t.Fatalf("v49 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("v50 staging table remained after rollback to v49")
|
||||
}
|
||||
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("v49 to v50 state=%q err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.MigrateDown(48); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var grants int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM system_grants g JOIN users u ON u.id=g.user_id
|
||||
WHERE u.owner='admin' AND g.allowed=TRUE`).Scan(&grants); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grants != 12 {
|
||||
t.Fatalf("legacy administrator grants=%d", grants)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ := st.GetAccountByOwner("admin")
|
||||
alice, _ := st.GetAccountByOwner("alice")
|
||||
bob, _ := st.GetAccountByOwner("bob")
|
||||
return admin, alice, bob
|
||||
}
|
||||
|
||||
func TestAssignUserRoleKeepsOneEffectiveBinding(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, bob := rolePolicyAccounts(t, st)
|
||||
engineer, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师", Description: "软件研发"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewer, err := st.CreateRole("admin", RoleCreate{Name: "审核工程师"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := st.AssignUserRole("admin", alice.ID, engineer.RoleID, alice.StateVersion, "assign-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", bob.ID, engineer.RoleID, bob.StateVersion, "assign-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := st.AssignUserRole("admin", alice.ID, reviewer.RoleID, first.UserStateVersion, "assign-3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.RoleID != reviewer.RoleID {
|
||||
t.Fatalf("binding=%+v", second)
|
||||
}
|
||||
var active, engineerUsers int
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE user_id=? AND active=?`), alice.ID, true).Scan(&active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), engineer.RoleID, true).Scan(&engineerUsers); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active != 1 || engineerUsers != 1 {
|
||||
t.Fatalf("alice active=%d engineer users=%d", active, engineerUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignUserRoleConcurrentExpectedVersionAllowsOne(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
first, _ := st.CreateRole("admin", RoleCreate{Name: "职位一"})
|
||||
second, _ := st.CreateRole("admin", RoleCreate{Name: "职位二"})
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for i, roleID := range []string{first.RoleID, second.RoleID} {
|
||||
wg.Add(1)
|
||||
go func(index int, id string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := st.AssignUserRole("admin", alice.ID, id, alice.StateVersion, "race-"+string(rune('a'+index)))
|
||||
errs <- err
|
||||
}(i, roleID)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
var passed, conflicted int
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
passed++
|
||||
case errors.Is(err, ErrConflict):
|
||||
conflicted++
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if passed != 1 || conflicted != 1 {
|
||||
t.Fatalf("passed=%d conflicted=%d", passed, conflicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRolePermissionsIsAtomicAndIdempotent(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: false},
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "policy-bad"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("invalid batch: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=?`), role.RoleID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial writes=%d err=%v", count, err)
|
||||
}
|
||||
good := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good")
|
||||
if err != nil || replayed.ReceiptID != receipt.ReceiptID || replayed.AuthorityEpoch != receipt.AuthorityEpoch {
|
||||
t.Fatalf("replay=%+v err=%v original=%+v", replayed, err, receipt)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-stale"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale role version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
admin, alice, _ := rolePolicyAccounts(t, st)
|
||||
epoch, err := st.AuthorityEpoch()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-last"); !errors.Is(err, ErrLastPermissionManager) {
|
||||
t.Fatalf("remove final manager: %v", err)
|
||||
}
|
||||
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "add-second"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, _ = st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
description := "负责软件研发"
|
||||
updated, err := st.PatchRole("admin", role.RoleID, role.RoleVersion, RolePatch{Description: &description})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.RoleID != role.RoleID || updated.Description != description || updated.RoleVersion != role.RoleVersion+1 {
|
||||
t.Fatalf("updated role=%+v original=%+v", updated, role)
|
||||
}
|
||||
disabled := false
|
||||
updated, err = st.PatchRole("admin", role.RoleID, updated.RoleVersion, RolePatch{Enabled: &disabled})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "disabled-role"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("disabled role assignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, first, "same-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, changed, "same-key"); !errors.Is(err, ErrConflict) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,11 @@ import (
|
||||
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
|
||||
// 2.24.0-experimental:EXT-20 可选 RegulationsHost 与受信进程内 RegulationsRegistrar
|
||||
// (共享法规检索与网络证据;仅插件 kbreg 可注册提供方)。
|
||||
const SurfaceVersion = "2.24.0-experimental"
|
||||
// 2.25.0-experimental: IAM U4 optional RolePolicyProvider and
|
||||
// SystemActionProvider for the Backend-owned single-effective-role policy;
|
||||
// resource queries carry the current authority epoch so downstream caches can
|
||||
// reject stale role snapshots.
|
||||
const SurfaceVersion = "2.25.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
@@ -83,6 +87,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 布尔)之上的
|
||||
|
||||
@@ -48,6 +48,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
|
||||
// - WorkCompletionHost — X1:受信项目工作生成
|
||||
// - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定
|
||||
// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应
|
||||
// - RolePolicyProvider — Backend 权威职位与动作默认/上限
|
||||
type Host interface {
|
||||
// PluginID 返回注册处理器的插件标识(形如 rdm)。
|
||||
PluginID() string
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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)
|
||||
RolePolicyForOwner(context.Context, string) (RolePolicySnapshot, error)
|
||||
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"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RoleVersion int64 `json:"roleVersion"`
|
||||
BindingCount int `json:"bindingCount"`
|
||||
Permissions map[string]RolePermission `json:"permissions"`
|
||||
}
|
||||
|
||||
type RoleBindingResult struct {
|
||||
BindingID string `json:"bindingId"`
|
||||
UserID string `json:"userId"`
|
||||
RoleID string `json:"roleId"`
|
||||
UserStateVersion int64 `json:"userStateVersion"`
|
||||
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 {
|
||||
ListRoles(context.Context) ([]RoleAdminRecord, error)
|
||||
CreateRole(context.Context, string, string) (RoleAdminRecord, error)
|
||||
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)
|
||||
}
|
||||
@@ -180,6 +180,30 @@ ResetCompiled
|
||||
ResourceAuthorizer
|
||||
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
|
||||
RolePolicyProvider.CheckRoleAction
|
||||
RolePolicyProvider.CurrentRolePolicy
|
||||
RolePolicyProvider.RolePolicyForOwner
|
||||
RolePolicyProvider.RolePolicyForUser
|
||||
RolePolicySchemaVersion
|
||||
RolePolicySnapshot
|
||||
RunCLIHooks
|
||||
SetRunner
|
||||
SetScopedHostFactory
|
||||
@@ -190,6 +214,9 @@ SurfaceHost.SurfaceList
|
||||
SurfaceHost.SurfaceSave
|
||||
SurfaceSaveRequest
|
||||
SurfaceVersion
|
||||
SystemActionDecision
|
||||
SystemActionProvider
|
||||
SystemActionProvider.CheckCurrentSystemAction
|
||||
WebCapture
|
||||
WebCaptureRequest
|
||||
WebCaptureStateCaptured
|
||||
|
||||
Reference in New Issue
Block a user