mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 19:56:01 +00:00
3324ef3909
d235aab3c put MigrateFromFiles on a production path for the default org and
failed NewSQLiteManager whenever the import was rejected. That propagated to
GetManager, so every RBAC route returned 503 including ResetAdminRole, which
is the operator's only way back. The realistic trigger is a v5 install that
recreated a role the v6 store already holds under the same ID, so an
ordinary upgrade could leave an operator with no route to repair it short of
hand-editing the legacy JSON.
Rejecting the import is right and stays: importLegacyRBAC is transactional
and the legacy files are left in place, so a failure leaves the database
un-migrated rather than half-migrated. Missing roles deny access rather than
granting it, which is why the store is safe to keep serving.
The store now stays live and records the failure on MigrationError. The
deliberate fail-closed behaviour of the management surface is preserved
rather than removed: the handler manager accessors surface the migration
failure as the same 503 rbac_store_unavailable as before. Recovery reaches
the provider directly, so it is exempt by construction.
The existing 503 contract test is what caught the first attempt at this,
which simply let the surface serve un-migrated data. It now additionally
asserts recovery is reachable, so the two halves are pinned together.
560 lines
17 KiB
Go
560 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// validRoleID matches alphanumeric IDs with hyphens and underscores (1-64 chars)
|
|
var validRoleID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
|
|
|
// validUsername accepts local identities plus the colon-delimited opaque SSO
|
|
// principals emitted by stableSSOPrincipal. Slashes and path traversal
|
|
// characters remain excluded.
|
|
var validUsername = regexp.MustCompile(`^[a-zA-Z0-9._@+:-]{1,256}$`)
|
|
|
|
// RBACHandlers provides HTTP handlers for RBAC management.
|
|
type RBACHandlers struct {
|
|
config *config.Config
|
|
rbacProvider *TenantRBACProvider
|
|
}
|
|
|
|
// NewRBACHandlers creates a new RBACHandlers instance.
|
|
func NewRBACHandlers(cfg *config.Config, rbacProvider ...*TenantRBACProvider) *RBACHandlers {
|
|
var provider *TenantRBACProvider
|
|
if len(rbacProvider) > 0 {
|
|
provider = rbacProvider[0]
|
|
}
|
|
|
|
return &RBACHandlers{
|
|
config: cfg,
|
|
rbacProvider: provider,
|
|
}
|
|
}
|
|
|
|
// rbacMigrationFailure reports a store whose legacy import was rejected.
|
|
//
|
|
// The store itself stays live so admin recovery keeps working, but RBAC
|
|
// management routes must still fail closed: the operator is looking at data
|
|
// that does not include their legacy roles and assignments, and editing it
|
|
// would build on an incomplete picture. Recovery reaches the provider directly
|
|
// and so is deliberately not routed through this check.
|
|
func rbacMigrationFailure(manager any) error {
|
|
degraded, ok := manager.(interface{ MigrationError() error })
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return degraded.MigrationError()
|
|
}
|
|
|
|
// getManager returns the RBAC Manager for the org in the request context.
|
|
// Falls back to global manager if no provider is set (backward compat).
|
|
func (h *RBACHandlers) getManager(ctx context.Context) (auth.Manager, error) {
|
|
if h.rbacProvider != nil {
|
|
orgID := GetOrgID(ctx)
|
|
manager, err := h.rbacProvider.GetManager(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rbacMigrationFailure(manager); err != nil {
|
|
return nil, err
|
|
}
|
|
return manager, nil
|
|
}
|
|
return auth.GetManager(), nil
|
|
}
|
|
|
|
// getExtendedManager returns the ExtendedManager for the org in the request context.
|
|
func (h *RBACHandlers) getExtendedManager(ctx context.Context) (auth.ExtendedManager, error) {
|
|
if h.rbacProvider != nil {
|
|
orgID := GetOrgID(ctx)
|
|
manager, err := h.rbacProvider.GetManager(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rbacMigrationFailure(manager); err != nil {
|
|
return nil, err
|
|
}
|
|
return manager, nil
|
|
}
|
|
return auth.GetExtendedManager(), nil
|
|
}
|
|
|
|
func writeRBACStoreError(w http.ResponseWriter, err error) {
|
|
log.Error().Err(err).Msg("RBAC store is unavailable")
|
|
writeErrorResponse(
|
|
w,
|
|
http.StatusServiceUnavailable,
|
|
"rbac_store_unavailable",
|
|
"RBAC data could not be loaded; check the server logs before retrying",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// HandleRoles handles list, create, update, and delete actions for roles.
|
|
func (h *RBACHandlers) HandleRoles(w http.ResponseWriter, r *http.Request) {
|
|
manager, err := h.getManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if manager == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
|
|
return
|
|
}
|
|
|
|
roleID := strings.TrimPrefix(r.URL.Path, "/api/admin/roles")
|
|
roleID = strings.TrimPrefix(roleID, "/")
|
|
roleID = strings.TrimSuffix(roleID, "/")
|
|
|
|
// Validate role ID format if provided
|
|
if roleID != "" && !validRoleID.MatchString(roleID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if roleID == "" {
|
|
// List all roles
|
|
var roles []auth.Role
|
|
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
|
|
roles, err = errorAware.GetRolesWithError()
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
} else {
|
|
roles = manager.GetRoles()
|
|
}
|
|
if roles == nil {
|
|
roles = []auth.Role{}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(roles)
|
|
} else {
|
|
// Get specific role
|
|
role, ok := manager.GetRole(roleID)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusNotFound, "not_found", "Role not found", nil)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(role)
|
|
}
|
|
|
|
case http.MethodPost:
|
|
if roleID != "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "POST is for creating roles; do not include an ID in the path", nil)
|
|
return
|
|
}
|
|
|
|
// Limit request body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
|
|
|
|
var role auth.Role
|
|
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid role data", nil)
|
|
return
|
|
}
|
|
|
|
// Validate role ID in body
|
|
if role.ID != "" && !validRoleID.MatchString(role.ID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
|
|
return
|
|
}
|
|
|
|
if err := manager.SaveRole(role); err != nil {
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_create_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to create role %s", role.ID))
|
|
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save role", nil)
|
|
return
|
|
}
|
|
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_created", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Created role "+role.ID)
|
|
RecordRBACRoleMutation("create")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(role)
|
|
|
|
case http.MethodPut:
|
|
// Limit request body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
|
|
|
|
var role auth.Role
|
|
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid role data", nil)
|
|
return
|
|
}
|
|
|
|
if roleID != "" {
|
|
role.ID = roleID
|
|
}
|
|
|
|
// Validate role ID
|
|
if role.ID != "" && !validRoleID.MatchString(role.ID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
|
|
return
|
|
}
|
|
|
|
if err := manager.SaveRole(role); err != nil {
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_update_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to update role %s", role.ID))
|
|
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save role", nil)
|
|
return
|
|
}
|
|
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_updated", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Updated role "+role.ID)
|
|
RecordRBACRoleMutation("update")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(role)
|
|
|
|
case http.MethodDelete:
|
|
if roleID == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Role ID is required", nil)
|
|
return
|
|
}
|
|
|
|
if err := manager.DeleteRole(roleID); err != nil {
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_delete_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to delete role %s", roleID))
|
|
writeErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete role", nil)
|
|
return
|
|
}
|
|
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "role_deleted", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Deleted role "+roleID)
|
|
RecordRBACRoleMutation("delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HandleGetUsers lists users with their assigned roles.
|
|
func (h *RBACHandlers) HandleGetUsers(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
manager, err := h.getManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if manager == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
|
|
return
|
|
}
|
|
|
|
var assignments []auth.UserRoleAssignment
|
|
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
|
|
assignments, err = errorAware.GetUserAssignmentsWithError()
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
} else {
|
|
assignments = manager.GetUserAssignments()
|
|
}
|
|
if assignments == nil {
|
|
assignments = []auth.UserRoleAssignment{}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(assignments)
|
|
}
|
|
|
|
// HandleUserRoleActions handles assigning/updating roles for a user.
|
|
func (h *RBACHandlers) HandleUserRoleActions(w http.ResponseWriter, r *http.Request) {
|
|
manager, err := h.getManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if manager == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
|
|
return
|
|
}
|
|
|
|
// Extract username from path: /api/admin/users/{username}/roles
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/admin/users/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) < 1 || parts[0] == "" {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_username", "Username is required", nil)
|
|
return
|
|
}
|
|
username := parts[0]
|
|
|
|
// Validate username format to prevent injection
|
|
if !validUsername.MatchString(username) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_username", "Invalid username format", nil)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPut, http.MethodPost:
|
|
// Self-escalation prevention: users cannot modify their own role assignments.
|
|
// This prevents privilege escalation and ensures audit trail integrity.
|
|
// Only blocks writes — reads (GET) on own roles/permissions are allowed.
|
|
callerUser := auth.GetUser(r.Context())
|
|
if callerUser != "" && strings.EqualFold(callerUser, username) {
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_self_modify_denied", callerUser, GetClientIP(r), r.URL.Path, false, "Attempted to modify own role assignments")
|
|
writeErrorResponse(w, http.StatusForbidden, "self_modification_denied", "Cannot modify your own role assignments", nil)
|
|
return
|
|
}
|
|
|
|
// Limit request body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
|
|
|
|
var req struct {
|
|
RoleIDs []string `json:"roleIds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body", nil)
|
|
return
|
|
}
|
|
|
|
// Validate all role IDs
|
|
for _, roleID := range req.RoleIDs {
|
|
if !validRoleID.MatchString(roleID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", fmt.Sprintf("Invalid role ID format: %s", roleID), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := manager.UpdateUserRoles(username, req.RoleIDs); err != nil {
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_update_failed", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, false, fmt.Sprintf("Failed to update roles for user %s", username))
|
|
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update user roles", nil)
|
|
return
|
|
}
|
|
|
|
LogAuditEventForTenant(GetOrgID(r.Context()), "user_roles_updated", auth.GetUser(r.Context()), GetClientIP(r), r.URL.Path, true, "Updated roles for user "+username+": ["+strings.Join(req.RoleIDs, ", ")+"]")
|
|
RecordRBACRoleMutation("assign")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
case http.MethodGet:
|
|
// Get effective permissions
|
|
if len(parts) > 1 && parts[1] == "permissions" {
|
|
var perms []auth.Permission
|
|
if errorAware, ok := manager.(auth.ErrorAwareManager); ok {
|
|
perms, err = errorAware.GetUserPermissionsWithError(username)
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
} else {
|
|
perms = manager.GetUserPermissions(username)
|
|
}
|
|
if perms == nil {
|
|
perms = []auth.Permission{}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(perms)
|
|
return
|
|
}
|
|
|
|
// Get specific assignment
|
|
var assignment auth.UserRoleAssignment
|
|
var ok bool
|
|
if errorAware, supportsErrors := manager.(auth.ErrorAwareManager); supportsErrors {
|
|
assignment, ok, err = errorAware.GetUserAssignmentWithError(username)
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
} else {
|
|
assignment, ok = manager.GetUserAssignment(username)
|
|
}
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusNotFound, "not_found", "User assignment not found", nil)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(assignment)
|
|
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HandleRBACChangelog returns the RBAC change history.
|
|
func (h *RBACHandlers) HandleRBACChangelog(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
em, err := h.getExtendedManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if em == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC changelog is not available (requires Pro)", nil)
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
limit := 100
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if _, err := fmt.Sscanf(l, "%d", &limit); err == nil {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 100
|
|
}
|
|
}
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if _, err := fmt.Sscanf(o, "%d", &offset); err == nil {
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter by entity if provided
|
|
entityType := r.URL.Query().Get("entity_type")
|
|
entityID := r.URL.Query().Get("entity_id")
|
|
|
|
var logs []auth.RBACChangeLog
|
|
if entityType != "" && entityID != "" {
|
|
logs = em.GetChangeLogsForEntity(entityType, entityID)
|
|
} else {
|
|
logs = em.GetChangeLogs(limit, offset)
|
|
}
|
|
|
|
// Return empty array instead of null
|
|
if logs == nil {
|
|
logs = []auth.RBACChangeLog{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(logs)
|
|
}
|
|
|
|
// HandleRoleEffective returns a role with all inherited permissions.
|
|
func (h *RBACHandlers) HandleRoleEffective(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
em, err := h.getExtendedManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if em == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "Role inheritance is not available (requires Pro)", nil)
|
|
return
|
|
}
|
|
|
|
// Extract role ID from path: /api/admin/roles/{id}/effective
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/admin/roles/")
|
|
path = strings.TrimSuffix(path, "/effective")
|
|
roleID := strings.TrimSuffix(path, "/")
|
|
|
|
if roleID == "" || !validRoleID.MatchString(roleID) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_role_id", "Invalid role ID format", nil)
|
|
return
|
|
}
|
|
|
|
role, effectivePerms, ok := em.GetRoleWithInheritance(roleID)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusNotFound, "not_found", "Role not found", nil)
|
|
return
|
|
}
|
|
|
|
// Return role with effective permissions
|
|
response := struct {
|
|
auth.Role
|
|
EffectivePermissions []auth.Permission `json:"effectivePermissions"`
|
|
}{
|
|
Role: role,
|
|
EffectivePermissions: effectivePerms,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleUserEffectivePermissions returns a user's effective permissions with inheritance.
|
|
func (h *RBACHandlers) HandleUserEffectivePermissions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
manager, err := h.getManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if manager == nil {
|
|
writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil)
|
|
return
|
|
}
|
|
|
|
// Extract username from path: /api/admin/users/{username}/effective-permissions
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/admin/users/")
|
|
path = strings.TrimSuffix(path, "/effective-permissions")
|
|
username := strings.TrimSuffix(path, "/")
|
|
|
|
if username == "" || !validUsername.MatchString(username) {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_username", "Invalid username format", nil)
|
|
return
|
|
}
|
|
|
|
// Check if we have extended manager for inheritance
|
|
em, err := h.getExtendedManager(r.Context())
|
|
if err != nil {
|
|
writeRBACStoreError(w, err)
|
|
return
|
|
}
|
|
if em != nil {
|
|
roles := em.GetRolesWithInheritance(username)
|
|
|
|
// Collect all effective permissions
|
|
permMap := make(map[string]auth.Permission)
|
|
for _, role := range roles {
|
|
for _, perm := range role.Permissions {
|
|
key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect()
|
|
permMap[key] = perm
|
|
}
|
|
}
|
|
|
|
var perms []auth.Permission
|
|
for _, perm := range permMap {
|
|
perms = append(perms, perm)
|
|
}
|
|
|
|
response := struct {
|
|
Username string `json:"username"`
|
|
Roles []auth.Role `json:"roles"`
|
|
Permissions []auth.Permission `json:"permissions"`
|
|
}{
|
|
Username: username,
|
|
Roles: roles,
|
|
Permissions: perms,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// Fall back to basic permissions
|
|
perms := manager.GetUserPermissions(username)
|
|
if perms == nil {
|
|
perms = []auth.Permission{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(perms)
|
|
}
|