From 6b1afab82b480babdd3a99f2ef352446443273e7 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:47:09 +0000 Subject: [PATCH 1/7] 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 --- internal/authz/catalog.go | 61 +++++++++++++++ internal/authz/catalog_test.go | 29 +++++++ internal/store/migrate.go | 56 ++++++++++++++ internal/store/migrate_compat.go | 1 + internal/store/role_policy.go | 29 +++++++ internal/store/role_policy_migration_test.go | 81 ++++++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 internal/authz/catalog.go create mode 100644 internal/authz/catalog_test.go create mode 100644 internal/store/role_policy.go create mode 100644 internal/store/role_policy_migration_test.go diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go new file mode 100644 index 0000000..dcb7bb3 --- /dev/null +++ b/internal/authz/catalog.go @@ -0,0 +1,61 @@ +// Package authz owns the trusted action catalog used by system, role, and +// project authorization. Callers cannot invent an action or its scope. +package authz + +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.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}, +} + +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 +} diff --git a/internal/authz/catalog_test.go b/internal/authz/catalog_test.go new file mode 100644 index 0000000..2219d54 --- /dev/null +++ b/internal/authz/catalog_test.go @@ -0,0 +1,29 @@ +package authz + +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") + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 23ea6ce..7f91529 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -897,9 +897,65 @@ func init() { `ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`, `ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`, }}}, + migration{version: 35, name: "role_policy_authority", + up: map[string][]string{ + "sqlite": rolePolicyFoundationUp("INTEGER"), + "postgres": rolePolicyFoundationUp("BOOLEAN"), + }, + down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown}, + }, ) } +func rolePolicyFoundationUp(boolType string) []string { + return []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))`, + } +} + +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`, diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 066fcbd..2e5e67d 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -39,6 +39,7 @@ var migrationBreaksOldReaders = map[int]bool{ 32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段 + 35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go new file mode 100644 index 0000000..9534c05 --- /dev/null +++ b/internal/store/role_policy.go @@ -0,0 +1,29 @@ +package store + +import "time" + +// 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"` +} diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go new file mode 100644 index 0000000..2aba458 --- /dev/null +++ b/internal/store/role_policy_migration_test.go @@ -0,0 +1,81 @@ +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(34); 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 v35 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) + } +} From 74096dbdd7819916b7180d4f60c458afa23053a1 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:51:57 +0000 Subject: [PATCH 2/7] 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 --- internal/store/migrate.go | 12 +- internal/store/role_policy.go | 435 ++++++++++++++++++- internal/store/role_policy_migration_test.go | 21 + internal/store/role_policy_test.go | 195 +++++++++ 4 files changed, 653 insertions(+), 10 deletions(-) create mode 100644 internal/store/role_policy_test.go diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 7f91529..ee6bc6f 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -908,7 +908,7 @@ func init() { } func rolePolicyFoundationUp(boolType string) []string { - return []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, @@ -943,6 +943,16 @@ func rolePolicyFoundationUp(boolType string) []string { 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 []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", + } { + 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 rolePolicyFoundationDown = []string{ diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 9534c05..2e78135 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -1,6 +1,44 @@ package store -import "time" +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "agent-system-backend/internal/authz" +) + +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 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 { @@ -18,12 +56,391 @@ type RoleRecord struct { // 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"` + 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"` +} + +func normalizeRoleName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +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) 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 { + 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 { + 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 { + 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 := authz.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 { + 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) 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 := authz.DescriptorByID(actionID) + if !ok || descriptor.Scope != authz.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 } diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go index 2aba458..3e103eb 100644 --- a/internal/store/role_policy_migration_test.go +++ b/internal/store/role_policy_migration_test.go @@ -79,3 +79,24 @@ func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) { t.Fatalf("roles table missing after reapply: %v", err) } } + +func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(34); 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) + } +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go new file mode 100644 index 0000000..726e885 --- /dev/null +++ b/internal/store/role_policy_test.go @@ -0,0 +1,195 @@ +package store + +import ( + "errors" + "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) + now := nowISO() + if _, err := st.db.Exec(st.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?)`), admin.ID, "system.permissions.manage", true, 1, admin.ID, now); err != nil { + t.Fatal(err) + } + 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 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) + } +} From 4d1ca60067c22d237e1fee9b4aca3dcb8d7a5e5a Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:02:11 +0000 Subject: [PATCH 3/7] 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 --- cmd/storage-migrate/main.go | 3 +- internal/authz/{ => catalog}/catalog.go | 4 +- internal/authz/{ => catalog}/catalog_test.go | 2 +- internal/authz/service.go | 78 ++++++++ internal/server/account_search.go | 2 +- internal/server/account_write_fence_test.go | 1 + internal/server/accounts.go | 10 +- internal/server/api_key_fixture_test.go | 19 ++ internal/server/auth_e2e_test.go | 1 + internal/server/ext9_upload_switch_test.go | 1 + internal/server/project_lifecycle_test.go | 2 + internal/server/resource_authz_test.go | 1 + internal/server/role_authorization_test.go | 96 +++++++++ internal/server/roles.go | 181 +++++++++++++++++ internal/server/roles_test.go | 134 +++++++++++++ internal/server/server.go | 26 ++- internal/server/shared_retrieval_test.go | 3 + internal/store/account_tx.go | 8 + internal/store/migrate.go | 14 +- internal/store/role_policy.go | 197 ++++++++++++++++++- internal/store/role_policy_test.go | 5 - 21 files changed, 762 insertions(+), 26 deletions(-) rename internal/authz/{ => catalog}/catalog.go (97%) rename internal/authz/{ => catalog}/catalog_test.go (97%) create mode 100644 internal/authz/service.go create mode 100644 internal/server/role_authorization_test.go create mode 100644 internal/server/roles.go create mode 100644 internal/server/roles_test.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 84543fc..787c57c 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -281,7 +281,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", + "account_directory_revision", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", "activity_cards", "project_event_shares", "command_audit", "prompt_modules", diff --git a/internal/authz/catalog.go b/internal/authz/catalog/catalog.go similarity index 97% rename from internal/authz/catalog.go rename to internal/authz/catalog/catalog.go index dcb7bb3..5ddd7a6 100644 --- a/internal/authz/catalog.go +++ b/internal/authz/catalog/catalog.go @@ -1,6 +1,6 @@ -// Package authz owns the trusted action catalog used by system, role, and +// Package catalog owns the trusted action catalog used by system, role, and // project authorization. Callers cannot invent an action or its scope. -package authz +package catalog type Scope string diff --git a/internal/authz/catalog_test.go b/internal/authz/catalog/catalog_test.go similarity index 97% rename from internal/authz/catalog_test.go rename to internal/authz/catalog/catalog_test.go index 2219d54..66565d6 100644 --- a/internal/authz/catalog_test.go +++ b/internal/authz/catalog/catalog_test.go @@ -1,4 +1,4 @@ -package authz +package catalog import "testing" diff --git a/internal/authz/service.go b/internal/authz/service.go new file mode 100644 index 0000000..9db8a40 --- /dev/null +++ b/internal/authz/service.go @@ -0,0 +1,78 @@ +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 +} diff --git a/internal/server/account_search.go b/internal/server/account_search.go index 0139c43..2e2a0ee 100644 --- a/internal/server/account_search.go +++ b/internal/server/account_search.go @@ -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 } diff --git a/internal/server/account_write_fence_test.go b/internal/server/account_write_fence_test.go index bc0ea87..000b559 100644 --- a/internal/server/account_write_fence_test.go +++ b/internal/server/account_write_fence_test.go @@ -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) diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1b0b734..1c04a30 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -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 } @@ -405,7 +405,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")) @@ -417,7 +417,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 } @@ -442,7 +442,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 } diff --git a/internal/server/api_key_fixture_test.go b/internal/server/api_key_fixture_test.go index 62e808c..2e3788f 100644 --- a/internal/server/api_key_fixture_test.go +++ b/internal/server/api_key_fixture_test.go @@ -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) + } +} diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 91d2a22..85590fa 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -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 diff --git a/internal/server/ext9_upload_switch_test.go b/internal/server/ext9_upload_switch_test.go index 21e2ba5..ef3c8d0 100644 --- a/internal/server/ext9_upload_switch_test.go +++ b/internal/server/ext9_upload_switch_test.go @@ -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) diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index 6d38c67..c56fce9 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -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 { diff --git a/internal/server/resource_authz_test.go b/internal/server/resource_authz_test.go index a01e8ff..c89aeaa 100644 --- a/internal/server/resource_authz_test.go +++ b/internal/server/resource_authz_test.go @@ -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) diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go new file mode 100644 index 0000000..01ed5d5 --- /dev/null +++ b/internal/server/role_authorization_test.go @@ -0,0 +1,96 @@ +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/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) + } + } +} diff --git a/internal/server/roles.go b/internal/server/roles.go new file mode 100644 index 0000000..5fdbfc0 --- /dev/null +++ b/internal/server/roles.go @@ -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) +} diff --git a/internal/server/roles_test.go b/internal/server/roles_test.go new file mode 100644 index 0000000..5833d91 --- /dev/null +++ b/internal/server/roles_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c183f7c..aaeee79 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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/ingest" @@ -42,6 +43,7 @@ import ( ) type Server struct { + authorizer *authzsvc.Service accountSearchKey store.AccountCursorKey accountSearchKeyErr error projectLifecycle *extension.ProjectLifecycle @@ -163,7 +165,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} } @@ -309,6 +311,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) @@ -712,6 +725,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"` } @@ -2313,6 +2329,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 diff --git a/internal/server/shared_retrieval_test.go b/internal/server/shared_retrieval_test.go index 69f8b66..bc4a453 100644 --- a/internal/server/shared_retrieval_test.go +++ b/internal/server/shared_retrieval_test.go @@ -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) diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go index 7709a7f..57015f1 100644 --- a/internal/store/account_tx.go +++ b/internal/store/account_tx.go @@ -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 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index ee6bc6f..b0e275c 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -943,18 +943,20 @@ func rolePolicyFoundationUp(boolType string) []string { 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 []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", - } { + for _, actionID := range rolePolicyAdminActionsV35 { 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 rolePolicyAdminActionsV35 = []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`, diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 2e78135..8860ab7 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "agent-system-backend/internal/authz" + "agent-system-backend/internal/authz/catalog" ) var ( @@ -34,6 +34,19 @@ type RolePermissionChange struct { 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"` @@ -77,6 +90,29 @@ 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 < 35 { + 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 rolePolicyAdminActionsV35 { + 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) @@ -284,7 +320,7 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha } seen := map[string]bool{} for _, change := range changes { - descriptor, ok := authz.DescriptorByID(change.ActionID) + descriptor, ok := catalog.DescriptorByID(change.ActionID) if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) { return ErrInvalidRolePolicy } @@ -395,6 +431,159 @@ func (s *Store) AuthorityEpoch() (int64, error) { 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 +} + +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 @@ -412,8 +601,8 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a } seen := map[string]bool{} for _, actionID := range actions { - descriptor, ok := authz.DescriptorByID(actionID) - if !ok || descriptor.Scope != authz.ScopeSystem || seen[actionID] { + descriptor, ok := catalog.DescriptorByID(actionID) + if !ok || descriptor.Scope != catalog.ScopeSystem || seen[actionID] { return ErrInvalidRolePolicy } seen[actionID] = true diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index 726e885..f16865e 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -131,11 +131,6 @@ func TestReplaceRolePermissionsIsAtomicAndIdempotent(t *testing.T) { func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) { st := openTestStore(t) admin, alice, _ := rolePolicyAccounts(t, st) - now := nowISO() - if _, err := st.db.Exec(st.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at) - VALUES(?,?,?,?,?,?)`), admin.ID, "system.permissions.manage", true, 1, admin.ID, now); err != nil { - t.Fatal(err) - } epoch, err := st.AuthorityEpoch() if err != nil { t.Fatal(err) From 5abd851256a9cdbf4863456f0644c4e67fb72e8d Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:09:11 +0000 Subject: [PATCH 4/7] 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 --- internal/app/hostcaps.go | 104 +++++++++++++++++++++++++++++ internal/app/role_provider_test.go | 76 +++++++++++++++++++++ pkg/extension/extension.go | 4 +- pkg/extension/host.go | 1 + pkg/extension/role_policy.go | 44 ++++++++++++ pkg/extension/surface.txt | 8 +++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 internal/app/role_provider_test.go create mode 100644 pkg/extension/role_policy.go diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 7ffd00c..51abd53 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -8,6 +8,7 @@ import ( "strings" "sync" + "agent-system-backend/internal/authz/catalog" "agent-system-backend/internal/ingest" "agent-system-backend/internal/rag" "agent-system-backend/internal/runtime" @@ -305,6 +306,109 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account return entries, nil } +func (h inprocHost) rolePolicyActor() (store.Account, error) { + if h.actor == "" { + return store.Account{}, store.ErrAccountForbidden + } + account, err := h.caps.st.GetAccountByOwner(h.actor) + if err != nil || !account.Enabled { + return store.Account{}, store.ErrAccountForbidden + } + return account, nil +} + +func (h inprocHost) CurrentRolePolicy(ctx context.Context) (extension.RolePolicySnapshot, error) { + if err := ctx.Err(); err != nil { + return extension.RolePolicySnapshot{}, err + } + actor, err := h.rolePolicyActor() + if err != nil { + return extension.RolePolicySnapshot{}, err + } + return h.RolePolicyForUser(ctx, actor.ID) +} + +func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (extension.RolePolicySnapshot, error) { + if err := ctx.Err(); err != nil { + return extension.RolePolicySnapshot{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return extension.RolePolicySnapshot{}, err + } + account, err := h.caps.st.GetAccount(userID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + epoch, err := h.caps.st.AuthorityEpoch() + if err != nil { + return extension.RolePolicySnapshot{}, err + } + snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, + UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled, + AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}} + view, err := h.caps.st.RoleForUser(userID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + if view.Binding == nil || view.Role == nil { + return snapshot, nil + } + snapshot.BindingID = view.Binding.BindingID + snapshot.RoleID = view.Role.RoleID + snapshot.RoleName = view.Role.Name + snapshot.RoleDescription = view.Role.Description + snapshot.RoleEnabled = view.Role.Enabled + snapshot.RoleVersion = view.Role.RoleVersion + snapshot.AuthorityEpoch = view.Binding.AuthorityEpoch + permissions, err := h.caps.st.RolePermissions(view.Role.RoleID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + for _, permission := range permissions { + snapshot.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed} + } + return snapshot, nil +} + +func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) { + decision := extension.RoleActionDecision{ActionID: actionID} + if _, ok := catalog.DescriptorByID(actionID); !ok { + decision.Reason = "unknown-action" + return decision, nil + } + snapshot, err := h.RolePolicyForUser(ctx, userID) + if err != nil { + return decision, err + } + decision.RoleID = snapshot.RoleID + decision.AuthorityEpoch = snapshot.AuthorityEpoch + if !snapshot.AccountEnabled { + decision.Reason = "account-disabled" + return decision, nil + } + if snapshot.RoleID == "" { + decision.Reason = "role-unassigned" + return decision, nil + } + if !snapshot.RoleEnabled { + decision.Reason = "role-disabled" + return decision, nil + } + permission, ok := snapshot.Permissions[actionID] + if !ok { + decision.Reason = "action-unconfigured" + return decision, nil + } + decision.Allowed = permission.Allowed + decision.CapAllowed = permission.CapAllowed + if !permission.CapAllowed { + decision.Reason = "role-cap" + } else if !permission.Allowed { + decision.Reason = "role-default-deny" + } + return decision, nil +} + func (h inprocHost) KVPut(key, value string) error { if err := h.requireEffective(); err != nil { return err diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go new file mode 100644 index 0000000..1e45377 --- /dev/null +++ b/internal/app/role_provider_test.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + role, err := st.CreateRole("admin", store.RoleCreate{Name: "软件工程师", Description: "负责软件研发"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []store.RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "provider-policy") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "provider-binding"); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"} + provider, ok := any(h).(extension.RolePolicyProvider) + if !ok { + t.Fatal("dispatch host missing RolePolicyProvider") + } + snapshot, err := provider.RolePolicyForUser(context.Background(), alice.ID) + if err != nil { + t.Fatal(err) + } + if snapshot.SchemaVersion != extension.RolePolicySchemaVersion || snapshot.UserID != alice.ID || !snapshot.AccountEnabled || + snapshot.RoleID != role.RoleID || snapshot.RoleName != "软件工程师" || snapshot.RoleDescription != "负责软件研发" || + !snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch { + t.Fatalf("snapshot=%+v", snapshot) + } + permission := snapshot.Permissions["document.read"] + if !permission.Allowed || !permission.CapAllowed { + t.Fatalf("permission=%+v", permission) + } + decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read") + if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID { + t.Fatalf("decision=%+v err=%v", decision, err) + } +} + +func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: ""} + provider := any(h).(extension.RolePolicyProvider) + if _, err := provider.RolePolicyForUser(context.Background(), alice.ID); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("empty actor provider: %v", err) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index bbfe1d1..2f63ae4 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -36,7 +36,9 @@ import ( // ResourceAuthzQuery.Source for per-document publication authorization. // 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process // AccountDirectoryRegistrar for scoped, current account candidate decisions. -const SurfaceVersion = "2.13.0-experimental" +// 2.14.0-experimental: optional RolePolicyProvider for the Backend-owned +// single-effective-role policy consumed by trusted in-process domains. +const SurfaceVersion = "2.14.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/host.go b/pkg/extension/host.go index c528b81..489439c 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -41,6 +41,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error) // 可选能力接口(type assertion 获取,断言失败=明确报错): // - AdminProvider — EXT-18:调用方平台管理角色查询 // - SurfaceHost — EXT-17:页面保存/读取 +// - RolePolicyProvider — Backend 权威职位与动作默认/上限 type Host interface { // PluginID 返回注册处理器的插件标识(形如 rdm)。 PluginID() string diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go new file mode 100644 index 0000000..2bd374d --- /dev/null +++ b/pkg/extension/role_policy.go @@ -0,0 +1,44 @@ +package extension + +import "context" + +const RolePolicySchemaVersion = 1 + +type RolePermission struct { + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` +} + +// RolePolicySnapshot contains authorization metadata only. It intentionally +// excludes profile contact fields and credentials. +type RolePolicySnapshot struct { + SchemaVersion int `json:"schemaVersion"` + UserID string `json:"userId"` + Owner string `json:"owner"` + AccountEnabled bool `json:"accountEnabled"` + BindingID string `json:"bindingId,omitempty"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleDescription string `json:"roleDescription,omitempty"` + RoleEnabled bool `json:"roleEnabled"` + RoleVersion int64 `json:"roleVersion,omitempty"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Permissions map[string]RolePermission `json:"permissions"` +} + +type RoleActionDecision struct { + ActionID string `json:"actionId"` + RoleID string `json:"roleId,omitempty"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Reason string `json:"reason,omitempty"` +} + +// RolePolicyProvider is optional on a trusted dispatch Host. Its actor is +// bound by the Backend; callers cannot supply or replace that actor. +type RolePolicyProvider interface { + CurrentRolePolicy(context.Context) (RolePolicySnapshot, error) + RolePolicyForUser(context.Context, string) (RolePolicySnapshot, error) + CheckRoleAction(context.Context, string, string) (RoleActionDecision, error) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index ca7118a..54a07aa 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -79,6 +79,14 @@ ResetCompiled ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery +RoleActionDecision +RolePermission +RolePolicyProvider +RolePolicyProvider.CheckRoleAction +RolePolicyProvider.CurrentRolePolicy +RolePolicyProvider.RolePolicyForUser +RolePolicySchemaVersion +RolePolicySnapshot SetRunner SetScopedHostFactory SurfaceHost From 94732f3c875ade1e511b970b8c754c2b7bec4dce Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:28:31 +0000 Subject: [PATCH 5/7] 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 --- internal/app/hostcaps.go | 161 +++++++++++++++++++++++++++++ internal/app/role_provider_test.go | 39 +++++++ internal/authz/catalog/catalog.go | 6 ++ pkg/extension/role_policy.go | 29 ++++++ pkg/extension/surface.txt | 9 ++ 5 files changed, 244 insertions(+) diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 51abd53..3312b3f 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -5,9 +5,11 @@ import ( "context" "fmt" "sort" + "strconv" "strings" "sync" + authzsvc "agent-system-backend/internal/authz" "agent-system-backend/internal/authz/catalog" "agent-system-backend/internal/ingest" "agent-system-backend/internal/rag" @@ -370,6 +372,17 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten 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 { @@ -409,6 +422,154 @@ func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string return decision, 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) KVPut(key, value string) error { if err := h.requireEffective(); err != nil { return err diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go index 1e45377..1804387 100644 --- a/internal/app/role_provider_test.go +++ b/internal/app/role_provider_test.go @@ -43,6 +43,10 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) { 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 { @@ -74,3 +78,38 @@ func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) { 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) + } +} diff --git a/internal/authz/catalog/catalog.go b/internal/authz/catalog/catalog.go index 5ddd7a6..29c7271 100644 --- a/internal/authz/catalog/catalog.go +++ b/internal/authz/catalog/catalog.go @@ -32,6 +32,8 @@ var descriptors = []Descriptor{ {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}, @@ -39,6 +41,10 @@ var descriptors = []Descriptor{ {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 { diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index 2bd374d..f9dab8b 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -40,5 +40,34 @@ type RoleActionDecision struct { 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 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"` +} + +// 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) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 54a07aa..a738dbe 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -80,10 +80,19 @@ ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery RoleActionDecision +RoleAdminRecord +RoleBindingResult RolePermission +RolePolicyAdmin +RolePolicyAdmin.AssignRoleByOwner +RolePolicyAdmin.CreateRole +RolePolicyAdmin.ListRoles +RolePolicyAdmin.SetRoleEnabled +RolePolicyAdmin.SetRolePermission RolePolicyProvider RolePolicyProvider.CheckRoleAction RolePolicyProvider.CurrentRolePolicy +RolePolicyProvider.RolePolicyForOwner RolePolicyProvider.RolePolicyForUser RolePolicySchemaVersion RolePolicySnapshot From b2a1cd0a609974fabd61825020cb4d1414c1f7d6 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:37:39 +0000 Subject: [PATCH 6/7] 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 --- internal/authz/service.go | 13 +++++++++++++ internal/server/role_authorization_test.go | 4 ++++ internal/server/server.go | 1 + 3 files changed, 18 insertions(+) diff --git a/internal/authz/service.go b/internal/authz/service.go index 9db8a40..71f053a 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -76,3 +76,16 @@ func (s *Service) AuthorizeSystem(actor, actionID string) 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 +} diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 01ed5d5..47999ef 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -82,6 +82,10 @@ func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { 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 { diff --git a/internal/server/server.go b/internal/server/server.go index aaeee79..bde4b06 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -885,6 +885,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) } From 06ef17a70a558f431d8fc10327448bca79305cbb Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 02:27:50 +0000 Subject: [PATCH 7/7] 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 --- cmd/storage-migrate/main.go | 2 +- internal/app/app.go | 14 +- internal/app/hostcaps.go | 111 +++++- internal/app/resource_authz_v2_test.go | 16 + internal/app/role_provider_test.go | 17 + .../server/resource_authz_consumption_test.go | 6 +- internal/server/role_authorization_test.go | 52 +++ internal/server/server.go | 12 +- internal/store/account_directory.go | 4 +- internal/store/account_model.go | 16 +- internal/store/account_model_test.go | 3 + internal/store/account_state.go | 22 +- internal/store/migrate.go | 12 + internal/store/migrate_compat.go | 1 + internal/store/role_policy.go | 326 ++++++++++++++++++ internal/store/role_policy_migration_test.go | 20 ++ internal/store/role_policy_test.go | 71 ++++ pkg/extension/extension.go | 10 +- pkg/extension/role_policy.go | 45 +++ pkg/extension/surface.txt | 10 + 20 files changed, 748 insertions(+), 22 deletions(-) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 787c57c..9deaa07 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -281,7 +281,7 @@ func cmdBackup(args []string) error { // copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。 // admission_leases 是运行态执行许可(迁移无意义),明确排除。 var copyOrder = []string{ - "projects", "users", "roles", "role_permissions", "user_role_bindings", "system_grants", "authority_state", "policy_receipts", + "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", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", diff --git a/internal/app/app.go b/internal/app/app.go index 835884c..924557d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -620,7 +620,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{} directorySlot, err := newAccountDirectorySlot(st) if err != nil { diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 3312b3f..bbe40c6 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -42,6 +42,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) { @@ -67,12 +68,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 == "" { @@ -337,18 +345,22 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten if _, err := h.rolePolicyActor(); err != nil { return extension.RolePolicySnapshot{}, err } - account, err := h.caps.st.GetAccount(userID) + 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 := h.caps.st.AuthorityEpoch() + 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 := h.caps.st.RoleForUser(userID) + view, err := st.RoleForUser(userID) if err != nil { return extension.RolePolicySnapshot{}, err } @@ -361,8 +373,7 @@ func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (exten snapshot.RoleDescription = view.Role.Description snapshot.RoleEnabled = view.Role.Enabled snapshot.RoleVersion = view.Role.RoleVersion - snapshot.AuthorityEpoch = view.Binding.AuthorityEpoch - permissions, err := h.caps.st.RolePermissions(view.Role.RoleID) + permissions, err := st.RolePermissions(view.Role.RoleID) if err != nil { return extension.RolePolicySnapshot{}, err } @@ -422,6 +433,18 @@ func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string 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 @@ -570,6 +593,82 @@ func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled b 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 diff --git a/internal/app/resource_authz_v2_test.go b/internal/app/resource_authz_v2_test.go index 17dff17..ed3423e 100644 --- a/internal/app/resource_authz_v2_test.go +++ b/internal/app/resource_authz_v2_test.go @@ -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) + } +} diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go index 1804387..08494f0 100644 --- a/internal/app/role_provider_test.go +++ b/internal/app/role_provider_test.go @@ -60,6 +60,23 @@ func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) { 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) { diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index 0d0698b..19e8af9 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -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) } } diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 47999ef..570bd3a 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -98,3 +98,55 @@ func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { } } } + +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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bde4b06..f816145 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -141,9 +141,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 } @@ -2224,8 +2228,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 { diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go index bef0de9..157a0e3 100644 --- a/internal/store/account_directory.go +++ b/internal/store/account_directory.go @@ -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 { diff --git a/internal/store/account_model.go b/internal/store/account_model.go index 7c81fb6..0063cf4 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -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 { diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go index 26f34c7..7158365 100644 --- a/internal/store/account_model_test.go +++ b/internal/store/account_model_test.go @@ -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) } diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 87deb36..3f7c308 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -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 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index b0e275c..5bb6d5b 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -904,9 +904,21 @@ func init() { }, down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown}, }, + migration{version: 36, 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`}}, + }, ) } +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 ( diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 2e5e67d..c775bdb 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -40,6 +40,7 @@ var migrationBreaksOldReaders = map[int]bool{ 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段 35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏 + 36: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 8860ab7..1006901 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "sort" "strings" "time" @@ -86,6 +87,28 @@ type RoleBindingView struct { 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)) } @@ -186,6 +209,21 @@ func (s *Store) roleTx(tx *sql.Tx, roleID string, lock bool) (RoleRecord, error) 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 { @@ -193,6 +231,9 @@ func (s *Store) CreateRole(actor string, in RoleCreate) (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 + } 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) @@ -215,6 +256,9 @@ func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) { 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 @@ -307,6 +351,9 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha }{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 @@ -359,6 +406,9 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState 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 @@ -571,6 +621,33 @@ func (s *Store) SystemGrantAllowed(owner, actionID string) (bool, error) { 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 @@ -633,3 +710,252 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a }) 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 +} diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go index 3e103eb..ee192ff 100644 --- a/internal/store/role_policy_migration_test.go +++ b/internal/store/role_policy_migration_test.go @@ -80,6 +80,26 @@ func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) { } } +func TestRoleAuthorityImportStageUpgradesExistingV35(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(35); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil { + t.Fatalf("v35 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("v36 staging table remained after rollback to v35") + } + 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("v35 to v36 state=%q err=%v", state, err) + } +} + func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) { st := openTestStore(t) if err := st.MigrateDown(34); err != nil { diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index f16865e..99665f4 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "strings" "sync" "testing" ) @@ -147,6 +148,23 @@ func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) { } } +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) @@ -188,3 +206,56 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) { 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) + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 2f63ae4..feac709 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -38,7 +38,9 @@ import ( // AccountDirectoryRegistrar for scoped, current account candidate decisions. // 2.14.0-experimental: optional RolePolicyProvider for the Backend-owned // single-effective-role policy consumed by trusted in-process domains. -const SurfaceVersion = "2.14.0-experimental" +// 2.15.0-experimental: resource queries carry the current authority epoch so +// downstream caches can reject stale role snapshots. +const SurfaceVersion = "2.15.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -60,6 +62,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 布尔)之上的 diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go index f9dab8b..203dbe3 100644 --- a/pkg/extension/role_policy.go +++ b/pkg/extension/role_policy.go @@ -44,6 +44,19 @@ type RolePolicyProvider interface { 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"` @@ -62,6 +75,35 @@ type RoleBindingResult struct { 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 { @@ -70,4 +112,7 @@ type RolePolicyAdmin interface { 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) } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index a738dbe..d7b8813 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -81,12 +81,19 @@ 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 @@ -105,3 +112,6 @@ SurfaceHost.SurfaceList SurfaceHost.SurfaceSave SurfaceSaveRequest SurfaceVersion +SystemActionDecision +SystemActionProvider +SystemActionProvider.CheckCurrentSystemAction