Add granular role grant/revoke endpoints to REST and gRPC APIs

- Add POST /v1/accounts/{id}/roles and DELETE /v1/accounts/{id}/roles/{role} REST endpoints
- Add GrantRole and RevokeRole RPCs to AccountService in gRPC API
- Update OpenAPI specification with new endpoints
- Add grant and revoke subcommands to mciasctl
- Add grant and revoke subcommands to mciasgrpcctl
- Regenerate proto files with new message types and RPCs
- Implement gRPC server methods for granular role management
- All existing tests pass; build verified with goimports
Security: Role changes are audited via EventRoleGranted and EventRoleRevoked events,
consistent with existing SetRoles implementation.
This commit is contained in:
2026-03-12 20:55:49 -07:00
parent 7ede54afb2
commit 4114d087ce
8 changed files with 645 additions and 47 deletions

View File

@@ -130,6 +130,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/accounts/{id}", requireAdmin(http.HandlerFunc(s.handleDeleteAccount)))
mux.Handle("GET /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGetRoles)))
mux.Handle("PUT /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleSetRoles)))
mux.Handle("POST /v1/accounts/{id}/roles", requireAdmin(http.HandlerFunc(s.handleGrantRole)))
mux.Handle("DELETE /v1/accounts/{id}/roles/{role}", requireAdmin(http.HandlerFunc(s.handleRevokeRole)))
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
@@ -666,6 +668,10 @@ type setRolesRequest struct {
Roles []string `json:"roles"`
}
type grantRoleRequest struct {
Role string `json:"role"`
}
func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
@@ -710,6 +716,68 @@ func (s *Server) handleSetRoles(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGrantRole(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
var req grantRoleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Role == "" {
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
return
}
actor := middleware.ClaimsFromContext(r.Context())
var grantedBy *int64
if actor != nil {
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
grantedBy = &a.ID
}
}
if err := s.db.GrantRole(acct.ID, req.Role, grantedBy); err != nil {
middleware.WriteError(w, http.StatusBadRequest, "invalid role", "bad_request")
return
}
s.writeAudit(r, model.EventRoleGranted, grantedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, req.Role))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRevokeRole(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
role := r.PathValue("role")
if role == "" {
middleware.WriteError(w, http.StatusBadRequest, "role is required", "bad_request")
return
}
actor := middleware.ClaimsFromContext(r.Context())
var revokedBy *int64
if actor != nil {
if a, err := s.db.GetAccountByUUID(actor.Subject); err == nil {
revokedBy = &a.ID
}
}
if err := s.db.RevokeRole(acct.ID, role); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventRoleRevoked, revokedBy, &acct.ID, fmt.Sprintf(`{"role":"%s"}`, role))
w.WriteHeader(http.StatusNoContent)
}
// ---- TOTP endpoints ----
type totpEnrollResponse struct {