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
This commit is contained in:
weichen
2026-10-14 16:47:09 +00:00
parent 2716b9e607
commit 6b1afab82b
6 changed files with 257 additions and 0 deletions
+61
View File
@@ -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
}
+29
View File
@@ -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")
}
}
+56
View File
@@ -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`,
+1
View File
@@ -39,6 +39,7 @@ var migrationBreaksOldReaders = map[int]bool{
32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+29
View File
@@ -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"`
}
@@ -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)
}
}