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
This commit is contained in:
weichen
2026-10-14 17:02:11 +00:00
parent 74096dbdd7
commit 4d1ca60067
21 changed files with 762 additions and 26 deletions
+2 -1
View File
@@ -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",
@@ -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
@@ -1,4 +1,4 @@
package authz
package catalog
import "testing"
+78
View File
@@ -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
}
+1 -1
View File
@@ -82,7 +82,7 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
return
}
actor, ok := s.requireAdmin(w, r)
actor, ok := s.requireSystemAction(w, r, "system.users.view")
if !ok {
return
}
@@ -42,6 +42,7 @@ func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) {
if err := st.ClearMustChangePassword("actor"); err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "actor")
key, err := st.CreateAPIKey("actor", "request")
if err != nil {
t.Fatal(err)
+5 -5
View File
@@ -311,7 +311,7 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b
}
func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
users, err := s.st.ListUsersAdmin()
@@ -319,7 +319,7 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
actor, ok := s.requireSystemAction(w, r, "system.users.create")
if !ok {
return
}
@@ -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
}
+19
View File
@@ -15,3 +15,22 @@ func createLegacyAPIKeyFixture(t *testing.T, st *store.Store, owner, name string
}
return st.CreateAPIKey(owner, name)
}
func grantProjectCreateFixture(t *testing.T, st *store.Store, owner string) {
t.Helper()
account, err := st.GetAccountByOwner(owner)
if err != nil {
t.Fatal(err)
}
role, err := st.CreateRole(owner, store.RoleCreate{Name: "项目创建测试职位-" + owner})
if err != nil {
t.Fatal(err)
}
if _, err := st.ReplaceRolePermissions(owner, role.RoleID, role.RoleVersion,
[]store.RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "fixture-policy-"+owner); err != nil {
t.Fatal(err)
}
if _, err := st.AssignUserRole(owner, account.ID, role.RoleID, account.StateVersion, "fixture-binding-"+owner); err != nil {
t.Fatal(err)
}
}
+1
View File
@@ -109,6 +109,7 @@ func TestTenantIsolationHTTP(t *testing.T) {
h := srv.Handler()
keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a")
keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b")
grantProjectCreateFixture(t, st, "alice")
do := func(key, method, path string, body any) int {
var buf bytes.Buffer
@@ -19,6 +19,7 @@ func TestUploadPreIndexSwitch(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "alice")
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(mustJSON(t, map[string]string{"name": "EXT9"})))
req.Header.Set("Authorization", "Bearer "+alice)
@@ -33,6 +33,7 @@ func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
grantProjectCreateFixture(t, st, "alice")
p, _ := st.CreateProject("alice", "Original")
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
calls := []string{}
@@ -205,6 +206,7 @@ func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
grantProjectCreateFixture(t, st, "alice")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
+1
View File
@@ -23,6 +23,7 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "alice")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
@@ -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)
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"net/http"
"strconv"
authzsvc "agent-system-backend/internal/authz"
"agent-system-backend/internal/store"
)
func (s *Server) requireSystemAction(w http.ResponseWriter, r *http.Request, actionID string) (string, bool) {
actor := s.owner(r)
if s.Auth == nil || !s.Auth.Enabled {
return actor, true
}
decision := s.authorizer.AuthorizeSystem(actor, actionID)
if !decision.Allowed {
http.Error(w, "system action denied: "+decision.Reason, http.StatusForbidden)
return "", false
}
return actor, true
}
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": authzsvc.Catalog()})
}
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
roles, next, err := s.st.ListRoles(r.URL.Query().Get("query"), r.URL.Query().Get("mode"),
r.URL.Query().Get("status"), r.URL.Query().Get("action"), r.URL.Query().Get("cursor"), limit)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles, "nextCursor": next})
}
func (s *Server) createRole(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.manage")
if !ok {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
role, err := s.st.CreateRole(actor, store.RoleCreate{Name: req.Name, Description: req.Description})
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, store.RoleAdminView{RoleRecord: role, Permissions: []store.RolePermissionRecord{}})
}
func (s *Server) getRole(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
role, err := s.st.RoleAdmin(r.PathValue("roleId"))
writeJSONOr(w, role, err)
}
func (s *Server) patchRole(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.manage")
if !ok {
return
}
var req struct {
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
Name *string `json:"name"`
Description *string `json:"description"`
Enabled *bool `json:"enabled"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedRoleVersion < 1 {
http.Error(w, "invalid role patch", http.StatusBadRequest)
return
}
role, err := s.st.PatchRole(actor, r.PathValue("roleId"), req.ExpectedRoleVersion,
store.RolePatch{Name: req.Name, Description: req.Description, Enabled: req.Enabled})
if err != nil {
writeStoreErr(w, err)
return
}
view, err := s.st.RoleAdmin(role.RoleID)
writeJSONOr(w, view, err)
}
func (s *Server) replaceRolePermissions(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
if !ok {
return
}
var req struct {
ExpectedRoleVersion int64 `json:"expectedRoleVersion"`
IdempotencyKey string `json:"idempotencyKey"`
Changes []store.RolePermissionChange `json:"changes"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
receipt, err := s.st.ReplaceRolePermissions(actor, r.PathValue("roleId"), req.ExpectedRoleVersion, req.Changes, req.IdempotencyKey)
writeJSONOr(w, receipt, err)
}
func (s *Server) getUserRole(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
view, err := s.st.RoleForUser(r.PathValue("userId"))
writeJSONOr(w, view, err)
}
func (s *Server) assignUserRole(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
if !ok {
return
}
var req struct {
RoleID string `json:"roleId"`
ExpectedStateVersion int64 `json:"expectedStateVersion"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.RoleID == "" || req.ExpectedStateVersion < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid role assignment", http.StatusBadRequest)
return
}
binding, err := s.st.AssignUserRole(actor, r.PathValue("userId"), req.RoleID, req.ExpectedStateVersion, req.IdempotencyKey)
writeJSONOr(w, binding, err)
}
func (s *Server) getSystemGrants(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
return
}
actions, epoch, err := s.st.SystemGrantsForUser(r.PathValue("userId"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"actions": actions, "authorityEpoch": epoch})
}
func (s *Server) replaceSystemGrants(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
if !ok {
return
}
var req struct {
ExpectedAuthorityEpoch int64 `json:"expectedAuthorityEpoch"`
IdempotencyKey string `json:"idempotencyKey"`
Actions []string `json:"actions"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.ExpectedAuthorityEpoch < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid system grant batch", http.StatusBadRequest)
return
}
receipt, err := s.st.ReplaceSystemGrants(actor, r.PathValue("userId"), req.ExpectedAuthorityEpoch, req.Actions, req.IdempotencyKey)
writeJSONOr(w, receipt, err)
}
func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Request) {
actionID := r.URL.Query().Get("action")
decision := s.authorizer.AuthorizeSystem(s.owner(r), actionID)
if actionID == "" {
http.Error(w, "action required", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, decision)
}
+134
View File
@@ -0,0 +1,134 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
type roleHTTPView struct {
RoleID string `json:"roleId"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
RoleVersion int64 `json:"roleVersion"`
BindingCount int `json:"bindingCount"`
}
func decodeRoleHTTP(t *testing.T, rec *httptest.ResponseRecorder) roleHTTPView {
t.Helper()
var role roleHTTPView
if err := json.Unmarshal(rec.Body.Bytes(), &role); err != nil {
t.Fatalf("decode role: %v body=%s", err, rec.Body.String())
}
return role
}
func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "system.roles.manage") {
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
}
createdRec := do(h, "POST", "/api/admin/roles", `{"name":"软件工程师","description":"负责软件研发"}`, admin)
if createdRec.Code != http.StatusOK {
t.Fatalf("create role: %d %s", createdRec.Code, createdRec.Body.String())
}
created := decodeRoleHTTP(t, createdRec)
if created.RoleID == "" || created.Description != "负责软件研发" || created.RoleVersion != 1 {
t.Fatalf("created=%+v", created)
}
list := do(h, "GET", "/api/admin/roles?query=软件工程师&mode=exact&status=enabled", "", admin)
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), created.RoleID) {
t.Fatalf("list role: %d %s", list.Code, list.Body.String())
}
patchedRec := do(h, "PATCH", "/api/admin/roles/"+created.RoleID,
`{"expectedRoleVersion":1,"description":"负责产品软件研发"}`, admin)
if patchedRec.Code != http.StatusOK {
t.Fatalf("patch role: %d %s", patchedRec.Code, patchedRec.Body.String())
}
patched := decodeRoleHTTP(t, patchedRec)
if patched.RoleID != created.RoleID || patched.Description != "负责产品软件研发" || patched.RoleVersion != 2 {
t.Fatalf("patched=%+v", patched)
}
permissions := do(h, "PUT", "/api/admin/roles/"+created.RoleID+"/permissions",
`{"expectedRoleVersion":2,"idempotencyKey":"role-policy-1","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
if permissions.Code != http.StatusOK {
t.Fatalf("permissions: %d %s", permissions.Code, permissions.Body.String())
}
userRec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"Alice"}}`, admin)
if userRec.Code != http.StatusOK {
t.Fatalf("create user: %d %s", userRec.Code, userRec.Body.String())
}
var user struct {
UserID string `json:"userId"`
StateVersion int64 `json:"stateVersion"`
}
if err := json.Unmarshal(userRec.Body.Bytes(), &user); err != nil {
t.Fatal(err)
}
assign := do(h, "PUT", "/api/admin/users/"+user.UserID+"/role",
`{"roleId":"`+created.RoleID+`","expectedStateVersion":`+"1"+`,"idempotencyKey":"assign-alice"}`, admin)
if assign.Code != http.StatusOK || !strings.Contains(assign.Body.String(), created.RoleID) {
t.Fatalf("assign: %d %s", assign.Code, assign.Body.String())
}
get := do(h, "GET", "/api/admin/users/"+user.UserID+"/role", "", admin)
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), created.RoleID) {
t.Fatalf("get binding: %d %s", get.Code, get.Body.String())
}
list = do(h, "GET", "/api/admin/roles?query=软件&mode=fuzzy&status=all", "", admin)
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"bindingCount":1`) {
t.Fatalf("binding count: %d %s", list.Code, list.Body.String())
}
}
func TestRoleHTTPUsesSpecificSystemActions(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
if err := st.CreateAccount("role-manager", "Role Manager", "", false); err != nil {
t.Fatal(err)
}
manager, err := st.GetAccountByOwner("role-manager")
if err != nil {
t.Fatal(err)
}
epoch, err := st.AuthorityEpoch()
if err != nil {
t.Fatal(err)
}
grant := do(h, "PUT", "/api/admin/users/"+manager.ID+"/system-grants",
`{"expectedAuthorityEpoch":`+strconv.FormatInt(epoch, 10)+`,"idempotencyKey":"grant-role-manager","actions":["system.roles.view","system.roles.manage"]}`, admin)
if grant.Code != http.StatusOK {
t.Fatalf("grant: %d %s", grant.Code, grant.Body.String())
}
key, err := st.CreateAPIKey("role-manager", "role-manager-test")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/admin/roles", strings.NewReader(`{"name":"测试职位"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("role manager create: %d %s", rec.Code, rec.Body.String())
}
role := decodeRoleHTTP(t, rec)
req = httptest.NewRequest("PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
strings.NewReader(`{"expectedRoleVersion":1,"idempotencyKey":"forbidden-policy","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("role manager changed permissions: %d %s", rec.Code, rec.Body.String())
}
}
+25 -1
View File
@@ -20,6 +20,7 @@ import (
"time"
agentcommand "agent-command"
authzsvc "agent-system-backend/internal/authz"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/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
+3
View File
@@ -23,6 +23,7 @@ func TestSharedKnowledgeRetrieval(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "alice")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
@@ -98,6 +99,7 @@ func TestProjectSSEScopeGate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "alice")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
@@ -151,6 +153,7 @@ func TestSharedCitationRefDownloadable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "alice")
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
if err != nil {
t.Fatal(err)
+8
View File
@@ -64,6 +64,11 @@ func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
if err := insertAccount(s, tx, w); err != nil {
return err
}
if w.IsAdmin {
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
return err
}
}
if w.PasswordHash == "" && w.Purpose != "" {
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
@@ -145,6 +150,9 @@ func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error)
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); err != nil {
return err
}
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
return err
}
var users int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
return err
+8 -6
View File
@@ -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`,
+193 -4
View File
@@ -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
-5
View File
@@ -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)