356 lines
13 KiB
Go
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})
|
|
}
|