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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user