Compare commits

..
10 Commits
Author SHA1 Message Date
weichen 6594e1d21b Merge pull request 'Add Backend role policy authority for IAM U4' (#213) from feature/iam-u4-role-authority into main
governance / governance (push) Successful in 19s
ci / vet-and-test (push) Successful in 1m4s
2026-10-18 14:20:29 +00:00
weichenandCursor b4a19e784d Merge Backend main into IAM U4 role authority
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m5s
Why: the U4 branch is 89 commits behind main, which now carries the shared
document renderer, two-trees project work, the SQLite instance guard and
exec env layers (PR #209 merged as c76d8bd); it conflicted in storage
migrations, host capabilities and the extension surface. The user authorized
syncing U4 after the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport 3b64eb5 (cursor worker, unit
backend-iam-u4-onto-main-c76d8bd-r1, run on a checkout whose tree equals main,
receipt /tmp/cleanup-20261018/render/u4-backend-onto-run.json, SELF). Host
check: main migrations 1-48 unchanged (migrate.go has no deleted lines versus
main), U4 migrations moved from 35-36 to 49-50 with identical SQL,
surface.txt is the sorted union, deps stay at main's pins.
Impact: databases that ran unmerged U4 builds with the old numbering must be
recreated. SurfaceVersion becomes 2.25.0-experimental. Main's admin password
account creation now requires the system.users.create action like the other
U4 account writes.
Refs: Backend main c76d8bd, PR #209

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:50:32 +00:00
weichen c8c49a6511 Merge remote-tracking branch 'origin/main' into feature/iam-u4-role-authority
# Conflicts:
#	pkg/extension/extension.go
#	pkg/extension/surface.txt
2026-10-15 03:56:22 +00:00
weichen 06ef17a70a Harden delegated role authority boundaries
Why: Independent review found delegated account actions, stale resource policy, and role migration cutover gaps in the initial U4 implementation.

Evidence: Backend full SQLite suite passed; isolated PostgreSQL role and migration suite passed; independent final review found no remaining findings.

Impact: Enforces transactional system grants, current role snapshots, staged role import with v36 migration, and fail-closed activation.

Refs: feature/iam-u4-role-authority
2026-10-15 02:27:50 +00:00
weichen b2a1cd0a60 Expose effective system actions to the client
Why: Non-root managers need the system-management entry and controls to follow their explicit grants.

Evidence: The full Backend test suite passed and /api/me action projection is covered by the grant-isolation test.

Impact: /api/me now returns only the current actor system actions evaluated by the same authorizer.

Refs: Focused role and permission system task 7
2026-10-14 17:37:39 +00:00
weichen 94732f3c87 Add trusted role administration compatibility
Why: Existing RDM role commands must reach the Backend authority during UI and migration cutover without dual-writing RDM role tables.

Evidence: The full Backend suite passed, including actor-bound role administration, owner compatibility, provider, and public-surface tests.

Impact: Adds temporary trusted Host administration methods and completes the project-action catalog used by RDM.

Refs: Focused role and permission system task 5
2026-10-14 17:28:31 +00:00
weichen 5abd851256 Expose the role policy provider
Why: RDM needs a trusted, non-secret view of Backend-owned role policy without importing Backend internals or accepting a caller-supplied actor.

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

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

Refs: Focused role and permission system task 4
2026-10-14 17:09:11 +00:00
weichen 4d1ca60067 Expose role and system permission APIs
Why: System, role, and project entry permissions need explicit actions instead of a single administrator flag or unrestricted project creation.

Evidence: The full Backend test suite passed, including role APIs, action isolation, project.create denial/allow, storage migration coverage, and account write fences.

Impact: Adds versioned role and grant endpoints, maps account actions to explicit permissions, and requires project.create authority.

Refs: Focused role and permission system task 3
2026-10-14 17:02:11 +00:00
weichen 74096dbdd7 Add atomic role and binding policies
Why: Role assignment and permission changes must preserve one active position, version conflicts, idempotency, and the final policy manager.

Evidence: The full Store suite passed on SQLite and focused role/policy tests passed on isolated PostgreSQL.

Impact: Adds transactional role mutations and seeds explicit system grants for existing administrators.

Refs: Focused role and permission system task 2
2026-10-14 16:51:57 +00:00
weichen 6b1afab82b Add the role permission catalog and schema
Why: Backend needs a single trusted catalog and durable role authority before RDM can stop owning position data.

Evidence: Authz catalog tests and the full store suite passed on SQLite; migration rollback and one-active-role constraints are covered.

Impact: Adds schema v35 and stable role/binding types without changing existing authorization callers.

Refs: Focused role and permission system task 1
2026-10-14 16:47:09 +00:00
37 changed files with 2887 additions and 26 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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)
}
}
+132
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+91
View File
@@ -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
}
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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
}
+19
View File
@@ -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)
}
}
+1
View File
@@ -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)
}
}
+1
View File
@@ -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)
+152
View File
@@ -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())
}
}
+181
View File
@@ -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)
}
+134
View File
@@ -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())
}
}
+36 -3
View File
@@ -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
+3
View File
@@ -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)
+2 -2
View File
@@ -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 {
+14 -6
View File
@@ -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 {
+3
View File
@@ -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)
}
+20 -2
View File
@@ -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
+8
View File
@@ -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
+80
View File
@@ -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`,
+2
View File
@@ -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 版本。
+2
View File
@@ -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 {
+961
View File
@@ -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)
}
}
+261
View File
@@ -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)
}
}
+11 -1
View File
@@ -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 布尔)之上的
+1
View File
@@ -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
+118
View File
@@ -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)
}
+27
View File
@@ -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