Files
Agent-System-Backend/internal/server/roles.go
T

356 lines
13 KiB
Go

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 {
if isAccountAdministrationAction(actionID) {
http.Error(w, "account administration requires authentication", http.StatusForbidden)
return "", false
}
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
}
// isAccountAdministrationAction covers user, role and permission management;
// the unauthenticated embedded mode must not expose any of them (R1).
func isAccountAdministrationAction(actionID string) bool {
return store.IsAccountAdministrationAction(actionID)
}
// systemActionAllowed reports, without writing a response, whether the actor
// holds a system action. Optional read projections (e.g. role summary in the
// account directory) use it to skip the projection when the caller cannot
// see it, so a missing grant stays invisible rather than being denied loudly.
func (s *Server) systemActionAllowed(actor, actionID string) bool {
if s.Auth == nil || !s.Auth.Enabled {
return true
}
return s.authorizer.AuthorizeSystem(actor, actionID).Allowed
}
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)
}
// replaceRolePermissions saves a role's project-access boolean. It is part of
// role management (R13); system grants stay behind system.permissions.manage.
func (s *Server) replaceRolePermissions(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"`
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)
}
// listRolePermissionPreview predicts role permission replace/patch effects
// without writing rows or receipts. Viewing is enough to call it; the commit
// revalidates every item.
func (s *Server) listRolePermissionPreview(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.view")
if !ok {
return
}
var req struct {
Roles []store.RolePermissionPreviewInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Roles) == 0 {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" {
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
return
}
}
previews, err := s.st.PreviewRolePermissions(actor, req.Roles)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
// batchPatchRolePermissions applies an explicit patch to several roles in
// one transaction. The actor must hold system.permissions.manage (R4, R5).
func (s *Server) batchPatchRolePermissions(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
if !ok {
return
}
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Roles []store.RolePermissionPatchInput `json:"roles"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.IdempotencyKey == "" || len(req.Roles) == 0 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
for _, role := range req.Roles {
if role.RoleID == "" || role.ExpectedRoleVersion < 1 {
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
return
}
}
batch, err := s.st.PatchRolePermissionsBatch(actor, req.Roles, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"receipts": batch.Receipts})
}
// decodeRoleAssignments parses a role assignment payload shared by the
// batch commit (withKey=true, requires idempotencyKey) and the read-only
// preview (withKey=false).
func decodeRoleAssignments(w http.ResponseWriter, r *http.Request, withKey bool) ([]store.UserRoleAssignmentInput, string, bool) {
var req struct {
IdempotencyKey string `json:"idempotencyKey"`
Assignments []store.UserRoleAssignmentInput `json:"assignments"`
}
if err := decodeStrictJSON(r, &req); err != nil || len(req.Assignments) == 0 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
if withKey && req.IdempotencyKey == "" {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
for _, assignment := range req.Assignments {
if assignment.UserID == "" || assignment.RoleID == "" || assignment.ExpectedStateVersion < 1 {
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
return nil, "", false
}
}
return req.Assignments, req.IdempotencyKey, true
}
func (s *Server) assignUserRolesBatch(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
if !ok {
return
}
assignments, key, ok := decodeRoleAssignments(w, r, true)
if !ok {
return
}
batch, err := s.st.AssignUserRolesBatch(actor, assignments, key)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"bindings": batch.Bindings})
}
// previewUserRoleAssignments reads the commit rule for each assignment and
// returns it without writing rows or receipts. Viewing is enough to call it.
func (s *Server) previewUserRoleAssignments(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
return
}
actor := s.owner(r)
assignments, _, ok := decodeRoleAssignments(w, r, false)
if !ok {
return
}
previews, err := s.st.PreviewUserRoleAssignments(actor, assignments)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
}
func (s *Server) copyAssignRole(w http.ResponseWriter, r *http.Request) {
var actor string
for _, actionID := range []string{"system.roles.manage", "system.permissions.manage", "system.roles.assign"} {
var ok bool
if actor, ok = s.requireSystemAction(w, r, actionID); !ok {
return
}
}
var req struct {
Source store.RoleCopySource `json:"source"`
Name string `json:"name"`
Description string `json:"description"`
Overrides []store.RolePermissionChange `json:"overrides"`
Assignments []store.RoleCopyAssignment `json:"assignments"`
IdempotencyKey string `json:"idempotencyKey"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Source.RoleID == "" || req.Source.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
http.Error(w, "invalid role copy", http.StatusBadRequest)
return
}
result, err := s.st.CreateRoleCopyAndAssign(actor, req.Source, store.RoleCreate{Name: req.Name, Description: req.Description},
req.Overrides, req.Assignments, req.IdempotencyKey)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"role": result.Role, "bindings": result.Bindings, "linkedExpansion": result.LinkedExpansion, "receiptId": result.ReceiptID, "authorityEpoch": result.AuthorityEpoch})
}