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)