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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -39,6 +39,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除
|
||||
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
|
||||
35: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user