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) + } +}