Table of Contents


Controller Security & Authorization

Implementing proper security and authorization in custom controllers is essential for protecting sensitive data and ensuring users can only access resources they're permitted to use. This section covers all aspects of securing controller actions in Magentrix.

Table of Contents

  1. Security Overview
  2. Authentication Basics
  3. Authorization with Attributes
  4. Entity-Level Permissions
  5. Role-Based Authorization
  6. Custom Authorization Logic
  7. Practical Security Examples
  8. Best Practices
  9. Common Security Pitfalls
  10. Security Checklist
  11. Troubleshooting Authorization Issues
  12. Advanced Security Patterns
  13. Summary
  14. Quick Reference
  15. Next Steps

Security Overview

Magentrix provides a comprehensive security model that operates at multiple levels:

Security Layers

┌─────────────────────────────────────────┐
│   1. Authentication (IsGuestUser)       │
│      ↓                                  │
│   2. Security Role Permissions          │
│      ↓                                  │
│   3. Entity Permissions                 │
│      ↓                                  │
│   4. Field-Level Security               │
│      ↓                                  │
│   5. Sharing Filters & Rules            │
│      ↓                                  │
│   6. Controller Authorization           │
└─────────────────────────────────────────┘

Controllers integrate with this security model using:

  • User authentication checks (SystemInfo.IsGuestUser, UserInfo.IsPortalUser)
  • Authorization attributes ([Authorize], [AuthorizeAction])
  • Custom permission logic in action methods

Authentication Basics

All controller actions require authentication by default. Users must be logged in to access any controller endpoint.

Checking User Authentication

public ActionResponse MyAction()
{
    // Check if user is logged in
    if (SystemInfo.IsGuestUser)
        return UnauthorizedAccessResponse();
    
    // User is authenticated, proceed
    return View();
}

Distinguishing User Types

public ActionResponse Dashboard()
{
    if (SystemInfo.IsGuestUser)
    {
        // Not logged in
        return Redirect("~/login");
    }
    
    if (UserInfo.IsPortalUser)
    {
        // Partner or Customer user
        return View("PartnerDashboard");
    }
    else
    {
        // Employee user
        return View("EmployeeDashboard");
    }
}

Practical Authentication Examples

Example 1: Employee-Only Action

public ActionResponse AdminPanel()
{
    // Only allow Employee users
    if (SystemInfo.IsGuestUser || UserInfo.IsPortalUser)
        return UnauthorizedAccessResponse();
    
    // Employee-only logic
    return View();
}

Example 2: Redirect Unauthenticated Users

public ActionResponse ProtectedResource()
{
    if (SystemInfo.IsGuestUser)
    {
        // Save the intended destination
        DataBag.ReturnUrl = Request.Url.PathAndQuery;
        
        // Redirect to login
        return Redirect("~/login");
    }
    
    // Show protected resource
    return View();
}

Authorization with Attributes

Magentrix provides two powerful attributes for declarative authorization: [Authorize] and [AuthorizeAction].

[Authorize] Attribute

Restrict actions to authenticated users or specific security roles.

Basic Authentication Check:

[Authorize]
public ActionResponse SecureAction()
{
    // Only authenticated users can access
    return View();
}

Role-Based Authorization:

[Authorize(Roles = "Administrator")]
public ActionResponse AdminOnly()
{
    // Only users with "Administrator" role
    return View();
}

Multiple Roles:

[Authorize(Roles = "Administrator,Sales Manager,Marketing Manager")]
public ActionResponse ManagerDashboard()
{
    // Users with any of these roles can access
    return View();
}
💡 Note: Roles are specified as comma-separated strings without spaces.

[AuthorizeAction] Attribute

Check entity-level permissions before allowing access to an action.

Basic Syntax:

[AuthorizeAction(Entity = "EntityName", Action = StandardAction.PermissionType)]

Parameters:

  • Entity: The API name of the entity (e.g., "Contact", "Opportunity", "Account")
  • Action: The type of permission to check (from StandardAction enum)
  • RecordIdParam (optional): The parameter name containing the record ID

StandardAction Enum Values

StandardActionDescription
StandardAction.ReadCheck if user can view entity records (list-level)
StandardAction.DetailCheck if user can view a specific record
StandardAction.CreateCheck if user can create new records
StandardAction.EditCheck if user can edit a specific record
StandardAction.DeleteCheck if user can delete a specific record

Entity-Level Permissions

Read Permission (List-Level)

Here's the updated section with the important note:


Read Permission (List-Level)

Check if user can view the entity at all:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Read)]
public override ActionResponse Index()
{
    // Only users with Read permission on Contact can access
    var contacts = Database.Query<Contact>().Limit(50).ToList();
    return View(contacts);
}

This checks: "Does the user have permission to view Contact records?"

⚠️ Important: Database.Query<Contact>() automatically filters results based on the user's security permissions. The query will only return contacts that the user has access to view, respecting:
  • Security role permissions (Private, Team, All, etc.)
  • Account-based filtering for portal users
  • Sharing rules and hierarchical permissions

What This Means:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Read)]
public override ActionResponse Index()
{
    var contacts = Database.Query<Contact>().Limit(50).ToList();
    // contacts list only contains records the user can access
    // - Employee with "Private" permission: only their own contacts
    // - Portal user: only contacts from their account
    // - Employee with "All" permission: all contacts
    
    return View(contacts);
}

Why Both Checks Matter:

  • [AuthorizeAction] - Checks if user has general Read permission on Contact entity
  • Database.Query<Contact>() - Automatically filters to only return records the user can access

Example - Different User Types See Different Data:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Read)]
public override ActionResponse Index()
{
    // All users execute the same query
    var contacts = Database.Query<Contact>()
        .OrderBy(c => c.LastName)
        .Limit(50)
        .ToList();
    
    // But results vary based on user permissions:
    // - Portal User A: sees only their account's contacts
    // - Portal User B: sees only their account's contacts (different from A)
    // - Employee with "Private": sees only contacts they own
    // - Employee with "All": sees all contacts
    
    return View(contacts);
}

This ensures both entity-level authorization and automatic record-level filtering work together seamlessly.


Detail Permission (Record-Level)

Check if user can view a specific record:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Detail)]
public ActionResponse ViewContact(string id)
{
    // Checks if user can view THIS specific contact
    var contact = Database.Retrieve(id);
    
    if (contact == null)
        return PageNotFound();
    
    return View(contact);
}

The system automatically:

  1. Extracts the id parameter from the URL
  2. Checks if the user has permission to view that specific Contact record
  3. Returns UnauthorizedAccessResponse() if permission is denied
⚠️ Important: Even with [AuthorizeAction], you should still check for null after Database.Retrieve<Contact>(id). If the user doesn't have access to the specific contact record (due to security role permissions, sharing rules, or account isolation), Database.Retrieve() will return null rather than throwing an authorization error.
Best Practice - Always Check for Null:
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Detail)]
public ActionResponse ViewContact(string id)
{
    var contact = Database.Retrieve(id);
    
    // Always check for null - could be deleted, no access, or doesn't exist
    if (contact == null)
        return PageNotFound();
    
    return View(contact);
}

Why Both Checks Matter:

  • [AuthorizeAction] - Checks if user has general Detail permission on Contact entity
  • Database.Retrieve() returning null - Indicates user doesn't have access to this specific record (or it doesn't exist)

Example - Portal User Access:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Detail)]
public ActionResponse ViewContact(string id)
{
    var contact = Database.Retrieve<Contact>(id);
    
    if (contact == null)
    {
        // Could be:
        // 1. Contact doesn't exist
        // 2. Portal user trying to access another account's contact
        // 3. User's security role doesn't grant access to this record
        return PageNotFound();
    }
    
    return View(contact);
}

This two-layer security ensures both entity-level and record-level authorization are properly enforced.


Create Permission

Check if user can create new records:

[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
public ActionResponse New()
{
    // Only users with Create permission can access
    return View(new Opportunity());
}

[HttpPost]
[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
public ActionResponse New(Opportunity model)
{
    if (ModelState.IsValid)
    {
        Database.Insert(model);
        return RedirectToAction("Index");
    }
    
    return View(model);
}

Edit Permission

Check if user can edit a specific record:

[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse Edit(string id)
{
    // Checks if user can edit THIS specific opportunity
    var opp = Database.Retrieve(id);
    return View(opp);
}

[HttpPost]
[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse Edit(Opportunity model)
{
    if (ModelState.IsValid)
    {
        Database.Update(model);
        return RedirectToAction("Index");
    }
    
    return View(model);
}

Delete Permission

Check if user can delete a specific record:

[HttpPost]
[AuthorizeAction(Entity = "Account", Action = StandardAction.Delete)]
public ActionResponse Delete(string id)
{
    // Checks if user can delete THIS specific account
    Database.Delete(id);
    AspxPage.AddMessage("Account deleted successfully.");
    return RedirectToAction("Index");
}

Custom RecordId Param

By default, [AuthorizeAction] looks for a parameter named id. If your parameter has a different name, specify it:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit, RecordIdParam = "contactId")]
public ActionResponse EditContact(string contactId)
{
    // Uses "contactId" instead of "id" for permission check
    Contact contact = Database.Retrieve(contactId);
    return View(contact);
}

Role-Based Authorization

Single Role Check

[Authorize(Roles = "Administrator")]
public ActionResponse SystemSettings()
{
    // Only Administrators can access
    return View();
}

Multiple Roles (OR Logic)

[Authorize(Roles = "Administrator,Sales Manager")]
public ActionResponse SalesReports()
{
    // Administrators OR Sales Managers can access
    return View();
}

💡 Note: This is OR logic - users need any one of the specified roles.

Combining Role and Entity Authorization

[Authorize(Roles = "Sales Manager,Sales Director")]
[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse ApproveOpportunity(string id)
{
    // User must have:
    // 1. Sales Manager OR Sales Director role
    // 2. Edit permission on the specific Opportunity
    
    var opp = Database.Retrieve<Opportunity>(id);
    opp.Status = "Approved";
    Database.Update(opp);
    
    return RedirectToAction("Details", new { id = id });
}

Custom Authorization Logic

Sometimes you need more complex authorization logic than attributes provide.

Manual Permission Checks

public ActionResponse ViewSensitiveData(string id)
{
    // Custom business logic for authorization
    var contact = Database.Retrieve<Contact>(id);
    
    // Check 1: User must be an employee
    if (UserInfo.IsPortalUser)
        return UnauthorizedAccessResponse();
    
    // Check 2: Only HR can view salary information
    if (contact.Department != "Human Resources")
        return UnauthorizedAccessResponse();
    
    // Check 3: Managers can only view their direct reports
    if (contact.ManagerId != UserInfo.UserId)
        return UnauthorizedAccessResponse();
    
    return View(contact);
}

Account-Based Authorization (Portal Users)

public ActionResponse ViewAccountDocuments(string accountId)
{
    if (UserInfo.IsPortalUser)
    {
        // Portal users can only view documents for their own account
        if (accountId != UserInfo.Account.Id)
            return UnauthorizedAccessResponse();
    }
    
    // Employee users can view any account
    var documents = Database.Query<Document>()
        .Limit(50)
        .ToList();
    
    return View(documents);
}

Conditional Authorization by Field Value

public ActionResponse ApproveOpportunity(string id)
{
    var opp = Database.Retrieve<Opportunity>(id);
    
    if (opp == null)
        return PageNotFound();
    
    // Only opportunities over $100K require manager approval
    if (opp.Amount > 100000)
    {
        // Load the user's role to check the role name
        var userRole = Database.Retrieve<Role>(UserInfo.RoleId);
        
        if (userRole == null || 
            (userRole.Name != "Sales Manager" && userRole.Name != "Sales Director"))
        {
            AspxPage.AddError("Opportunities over $100,000 require manager approval.");
            return RedirectToAction("Details", new { id = id });
        }
    }
    
    opp.Status = "Approved";
    Database.Update(opp);
    
    return RedirectToAction("Index");
}

Time-Based Authorization

public ActionResponse SubmitTimesheet()
{
    // Timesheets can only be submitted during business hours
    int hour = DateTime.Now.Hour;
    
    if (hour < 8 || hour > 18)
    {
        AspxPage.AddError("Timesheets can only be submitted between 8 AM and 6 PM.");
        return RedirectToAction("Index");
    }
    
    // Timesheets can only be submitted on weekdays
    if (DateTime.Now.DayOfWeek == DayOfWeek.Saturday || 
        DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
    {
        AspxPage.AddError("Timesheets cannot be submitted on weekends.");
        return RedirectToAction("Index");
    }
    
    return View(new Timesheet());
}

Ownership-Based Authorization

public ActionResponse EditOpportunity(string id)
{
    var opp = Database.Retrieve<Opportunity>(id);
    
    if (UserInfo.IsPortalUser)
    {
        // Portal users can only edit opportunities for their account
        if (opp.AccountId != UserInfo.AccountId)
            return UnauthorizedAccessResponse();
        
        // AND only if they're the primary contact
        if (opp.ContactId != UserInfo.ContactId)
            return UnauthorizedAccessResponse();
    }
    
    return View(opp);
}

Practical Security Examples

Example 1: Complete CRUD with Security

public class OpportunityManagerController : AspxController
{
    // List: Check Read permission
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Read)]
    public override ActionResponse Index()
    {
        var opportunities = Database.Query<Opportunity>()
            .OrderByDescending(o => o.CreatedDate)
            .Limit(50)
            .ToList();
        
        return View(opportunities);
    }
    
    // View details: Check Detail permission on specific record
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Detail)]
    public ActionResponse Details(string id)
    {
        var opp = Database.Retrieve(id);
        
        if (opp == null)
            return PageNotFound();
        
        return View(opp);
    }
    
    // Show create form: Check Create permission
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
    public ActionResponse New()
    {
        return View(new Opportunity());
    }
    
    // Save new record: Check Create permission
    [HttpPost]
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
    public ActionResponse New(Opportunity model)
    {
        if (ModelState.IsValid)
        {
            // Additional business logic: Set owner
            if (UserInfo.IsPortalUser)
            {
                model.AccountId = UserInfo.AccountId;
                model.ContactId = UserInfo.ContactId;
            }
            
            Database.Insert(model);
            AspxPage.AddMessage("Opportunity created successfully!");
            return RedirectToAction("Details", new { id = model.Id });
        }
        
        return View(model);
    }
    
    // Show edit form: Check Edit permission on specific record
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
    public ActionResponse Edit(string id)
    {
        var opp = Database.Retrieve(id);
        
        if (opp == null)
            return PageNotFound();
        
        return View(opp);
    }
    
    // Save changes: Check Edit permission on specific record
    [HttpPost]
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
    public ActionResponse Edit(Opportunity model)
    {
        if (ModelState.IsValid)
        {
            // Additional business logic: Closed opportunities require approval
            var original = Database.Retrieve(model.Id);
            
            if (model.Stage == "Closed Won" && original.Stage != "Closed Won")
            {
                // Check user's role name
                var roleName = String.Empty;

                if (UserInfo.Role != null)
                    roleName = UserInfo.Role.Name;
                else
                {
                    var userRole = Database.Retrieve<Role>(UserInfo.RoleId);
                    roleName = userRole?.Name;
                }
                
                if (roleName != "Sales Manager")
                {
                    AspxPage.AddError("Only Sales Managers can close opportunities as Won.");
                    return View(model);
                }
            }
            
            Database.Update(model);
            AspxPage.AddMessage("Opportunity updated successfully!");
            return RedirectToAction("Details", new { id = model.Id });
        }
        
        return View(model);
    }
    
    // Delete: Check Delete permission on specific record
    [HttpPost]
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Delete)]
    public ActionResponse Delete(string id)
    {
        try
        {
            // Additional business logic: Can't delete closed opportunities
            var opp = Database.Retrieve(id);
            
            if (opp == null)
                return PageNotFound();
            
            if (opp.IsClosed)
            {
                AspxPage.AddError("Closed opportunities cannot be deleted.");
                return RedirectToAction("Details", new { id = id });
            }
            
            Database.Delete<Opportunity>(id);
            AspxPage.AddMessage("Opportunity deleted successfully.");
            return RedirectToAction("Index");
        }
        catch (Exception ex)
        {
            SystemInfo.Error(ex);
            return RedirectToError("An error occurred while deleting the opportunity.");
        }
    }
}

Example 2: Multi-Tier Approval Workflow

public class ExpenseApprovalController : AspxController
{
    // Helper method to get current user's role name
    private string GetCurrentUserRoleName()
    {
        if (UserInfo.Role != null)
            return UserInfo.Role.Name;
        
        var role = Database.Retrieve<Role>(UserInfo.RoleId);
        return role?.Name;
    }
    
    // View pending expenses: Role-based
    [Authorize(Roles = "Expense Approver,Finance Manager")]
    public override ActionResponse Index()
    {
        var expenses = Database.Query<Expense>()
            .Where(e => e.Status == "Pending Approval")
            .OrderBy(e => e.SubmittedDate)
            .ToList();
        
        return View(expenses);
    }
    
    // Approve expense: Complex authorization
    [HttpPost]
    [Authorize(Roles = "Expense Approver,Finance Manager")]
    public ActionResponse Approve(string id, string comments)
    {
        var expense = Database.Retrieve<Expense>(id);
        
        if (expense == null)
            return PageNotFound();
        
        // Get user's role name
        var roleName = GetCurrentUserRoleName();
        
        // Authorization rule 1: Small expenses (<$500) can be approved by any Expense Approver
        if (expense.Amount < 500)
        {
            expense.Status = "Approved";
            expense.ApprovedBy = UserInfo.Name;
            expense.ApprovedDate = DateTime.Now;
            expense.Comments = comments;
            Database.Update(expense);
            
            AspxPage.AddMessage("Expense approved successfully.");
            return RedirectToAction("Index");
        }
        
        // Authorization rule 2: Medium expenses ($500-$2000) require Finance Manager
        if (expense.Amount >= 500 && expense.Amount < 2000)
        {
            if (roleName != "Finance Manager")
            {
                AspxPage.AddError("Expenses of $500 or more require Finance Manager approval.");
                return RedirectToAction("Details", new { id = id });
            }
            
            expense.Status = "Approved";
            expense.ApprovedBy = UserInfo.Name;
            expense.ApprovedDate = DateTime.Now;
            expense.Comments = comments;
            Database.Update(expense);
            
            AspxPage.AddMessage("Expense approved successfully.");
            return RedirectToAction("Index");
        }
        
        // Authorization rule 3: Large expenses ($2000+) require CFO approval
        if (expense.Amount >= 2000)
        {
            if (roleName != "CFO")
            {
                AspxPage.AddError("Expenses of $2,000 or more require CFO approval.");
                return RedirectToAction("Details", new { id = id });
            }
            
            expense.Status = "Approved";
            expense.ApprovedBy = UserInfo.Name;
            expense.ApprovedDate = DateTime.Now;
            expense.Comments = comments;
            Database.Update(expense);
            
            AspxPage.AddMessage("Expense approved successfully.");
            return RedirectToAction("Index");
        }
        
        return RedirectToAction("Index");
    }
}

Example 3: Partner Portal with Account Isolation

public class PartnerOpportunitiesController : AspxController
{
    // List opportunities for partner's account only
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Read)]
    public override ActionResponse Index()
    {
        if (UserInfo.IsPortalUser)
        {
            // Partners only see their account's opportunities
            var opportunities = Database.Query<Opportunity>()
                .Where(o => o.AccountId == (UserInfo as User).AccountId)
                .OrderByDescending(o => o.CreatedDate)
                .ToList();
            
            return View(opportunities);
        }
        else
        {
            // Employees see all opportunities
            var opportunities = Database.Query<Opportunity>()
                .OrderByDescending(o => o.CreatedDate)
                .ToList();
            
            return View(opportunities);
        }
    }
    
    // View opportunity: Enforce account isolation
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Detail)]
    public ActionResponse Details(string id)
    {
        var opp = Database.Retrieve<Opportunity>(id);
        
        if (opp == null)
            return PageNotFound();
        
        // Additional check: Partners can only view their account's opportunities
        if (UserInfo.IsPortalUser && opp.AccountId != UserInfo.AccountId)
            return UnauthorizedAccessResponse();
        
        return View(opp);
    }
    
    // Create opportunity: Automatically assign to partner's account
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
    public ActionResponse New()
    {
        var model = new Opportunity();
        
        if (UserInfo.IsPortalUser)
        {
            // Pre-fill with partner's account and contact
            model.AccountId = UserInfo.AccountId;
            model.ContactId = UserInfo.ContactId;
        }
        
        return View(model);
    }
    
    [HttpPost]
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Create)]
    public ActionResponse New(Opportunity model)
    {
        if (ModelState.IsValid)
        {
            // Security enforcement: Partners can't create opportunities for other accounts
            if (UserInfo.IsPortalUser)
            {
                model.AccountId = UserInfo.AccountId;
                model.ContactId = UserInfo.ContactId;
            }
            
            Database.Insert(model);
            AspxPage.AddMessage("Opportunity created successfully!");
            return RedirectToAction("Details", new { id = model.Id });
        }
        
        return View(model);
    }
    
    // Edit opportunity: Enforce account isolation
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
    public ActionResponse Edit(string id)
    {
        var opp = Database.Retrieve<Opportunity>(id);
        
        if (opp == null)
            return PageNotFound();
        
        // Additional check: Partners can only edit their account's opportunities
        if (UserInfo.IsPortalUser && opp.AccountId != UserInfo.AccountId)
            return UnauthorizedAccessResponse();
        
        return View(opp);
    }
    
    [HttpPost]
    [AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
    public ActionResponse Edit(Opportunity model)
    {
        if (ModelState.IsValid)
        {
            // Security enforcement: Partners can't reassign to other accounts
            if (UserInfo.IsPortalUser)
            {
                var original = Database.Retrieve<Opportunity>(model.Id);
                
                if (original.AccountId != UserInfo.AccountId)
                    return UnauthorizedAccessResponse();
                
                // Force account to remain the same
                model.AccountId = UserInfo.AccountId;
            }
            
            Database.Update(model);
            AspxPage.AddMessage("Opportunity updated successfully!");
            return RedirectToAction("Details", new { id = model.Id });
        }
        
        return View(model);
    }
}

Best Practices

1. Always Use Authorization Attributes When Possible

Preferred - Declarative:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)]
public ActionResponse Edit(string id)
{
    var contact = Database.Retrieve<Contact>(id);
    return View(contact);
}

Avoid - Manual checks unless necessary:

public ActionResponse Edit(string id)
{
    // Manual permission check - harder to maintain
    if (!CheckEditPermission(id))
        return UnauthorizedAccessResponse();
    
    var contact = Database.Retrieve<Contact>(id);
    return View(contact);
}

2. Combine Attributes for Layered Security

[Authorize(Roles = "Sales Manager")]
[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse ApproveOpportunity(string id)
{
    // User must have:
    // 1. Sales Manager role
    // 2. Edit permission on this specific Opportunity
    return View();
}

3. Fail Securely

Default to deny access:

public ActionResponse SensitiveAction(string id)
{
    // Deny by default
    if (UserInfo.IsPortalUser)
        return UnauthorizedAccessResponse();
    
    // Only employees reach here
    return View();
}

Don't default to allow:

public ActionResponse SensitiveAction(string id)
{
    // Get user's role name
    var roleName = String.Empty;

    if (UserInfo.Role != null)
        roleName = UserInfo.Role.Name;
    else
    {
        var role = Database.Retrieve<Role>(UserInfo.RoleId);
        roleName = role?.Name;
    }
    
    // Allow
    if (roleName == "Administrator")
        return View();
    
    // Deny all other users
    return UnauthorizedAccessResponse();
}

4. Enforce Account Isolation for Portal Users

public ActionResponse ViewData(string accountId)
{
    if (UserInfo.IsPortalUser)
    {
        // Always check account ownership for portal users
        if (accountId != UserInfo.Account.Id)
            return UnauthorizedAccessResponse();
    }
    
    // Proceed with logic
    return View();
}

5. Log Security Violations

public ActionResponse AdminAction()
{
    if (!UserInfo.Roles.Contains("Administrator"))
    {
        // Log the unauthorized attempt
        SystemInfo.Debug($"Unauthorized access attempt by {UserInfo.Name} ({UserInfo.Email}) to AdminAction");
        
        return UnauthorizedAccessResponse();
    }
    
    return View();
}

6. Use Meaningful Error Messages

Helpful:

AspxPage.AddError("Only Sales Managers can approve opportunities over $100,000.");

Vague:

AspxPage.AddError("Access denied.");

7. Check Permissions Early

public ActionResponse ProcessData(string id)
{
    // Check permission FIRST
    if (!HasPermission(id))
        return UnauthorizedAccessResponse();
    
    // THEN do expensive operations
    var data = Database.Query<LargeDataset>().ToList();
    // ... process data
    
    return View();
}

8. Test with Different User Types

Always test your controllers with:

  • ✅ Guest users (not logged in)
  • ✅ Employee users
  • ✅ Partner users
  • ✅ Customer users
  • ✅ Users with different security roles
  • ✅ Users from different accounts (for portal users)

Common Security Pitfalls

❌ Pitfall 1: Not Checking Authentication

public ActionResponse SensitiveData()
{
    // ❌ No authentication check!
    var data = GetSensitiveData();
    return View(data);
}

Solution:

[Authorize]
public ActionResponse SensitiveData()
{
    var data = GetSensitiveData();
    return View(data);
}

❌ Pitfall 2: Trusting User Input for Authorization

public ActionResponse ViewOpportunity(string id, string accountId)
{
    // ❌ Trusting accountId from user input!
    if (UserInfo.Account.Id == accountId)
    {
        var opp = Database.Retrieve<Opportunity>(id);
        return View(opp);
    }
    
    return UnauthorizedAccessResponse();
}

Solution:

public ActionResponse ViewOpportunity(string id)
{
    var opp = Database.Retrieve<Opportunity>(id);
    
    // ✅ Check the actual record's AccountId
    if (UserInfo.IsPortalUser && opp.AccountId != UserInfo.Account.Id)
    {
        return UnauthorizedAccessResponse();
    }
    
    return View(opp);
}

❌ Pitfall 3: Inconsistent Security Between GET and POST

// GET: Has authorization
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)]
public ActionResponse Edit(string id)
{
    return View(Database.Retrieve<Contact>(id));
}

// POST: Missing authorization! ❌
[HttpPost]
public ActionResponse Edit(Contact model)
{
    Database.Update(model);
    return RedirectToAction("Index");
}

Solution:

[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)]
public ActionResponse Edit(string id)
{
    return View(Database.Retrieve<Contact>(id));
}

[HttpPost]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)] // ✅ Added
public ActionResponse Edit(Contact model)
{
    Database.Update(model);
    return RedirectToAction("Index");
}

❌ Pitfall 4: Exposing Sensitive Data in URLs

// ❌ Passing sensitive data in URL
public ActionResponse ViewSalary(decimal salary)
{
    DataBag.Salary = salary;
    return View();
}
// URL: /aspx/Employees/ViewSalary?salary=150000

Solution:

// ✅ Pass only ID, retrieve sensitive data server-side
[Authorize(Roles = "HR Manager")]
public ActionResponse ViewSalary(string employeeId)
{
    var employee = Database.Retrieve<Employee>(employeeId);
    DataBag.Salary = employee.Salary;
    return View();
}
// URL: /aspx/Employees/ViewSalary?employeeId=005R000000haXk3IAE

❌ Pitfall 5: Not Validating Record Ownership

[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse Transfer(string id, string newOwnerId)
{
    var opp = Database.Retrieve<Opportunity>(id);
    opp.OwnerId = newOwnerId; // ❌ Anyone can transfer to anyone!
    Database.Update(opp);
    return RedirectToAction("Index");
}

 

[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse Transfer(string id, string newOwnerId)
{
    var opp = Database.Retrieve<Opportunity>(id);
    
    // ✅ Additional validation
    if (UserInfo.IsPortalUser)
    {
        AspxPage.AddError("Portal users cannot transfer opportunities.");
        return RedirectToAction("Details", new { id = id });
    }
    
    // ✅ Only managers can transfer
    if (!UserInfo.Roles.Contains("Sales Manager"))
    {
        AspxPage.AddError("Only Sales Managers can transfer opportunities.");
        return RedirectToAction("Details", new { id = id });
    }
    
    // ✅ Validate new owner exists and is valid
    var newOwner = Database.Retrieve<User>(newOwnerId);

    if (newOwner == null || !newOwner.IsActive)
    {
        AspxPage.AddError("Invalid owner specified.");
        return RedirectToAction("Details", new { id = id });
    }
    
    opp.OwnerId = newOwnerId;
    Database.Update(opp);
    
    AspxPage.AddMessage("Opportunity transferred successfully.");
    return RedirectToAction("Index");
}

Security Checklist

Use this checklist when developing controllers:

Authentication

  • [ ] All actions require authentication (or explicitly allow guest access)
  • [ ] Guest users are redirected to login when appropriate
  • [ ] User type checks are in place (Employee vs Portal user)

Authorization

  • [ ] [Authorize] or [AuthorizeAction] attributes used where appropriate
  • [ ] Role requirements are clearly defined
  • [ ] Entity permissions match business requirements
  • [ ] Both GET and POST actions have consistent authorization

Data Isolation

  • [ ] Portal users can only access their account's data
  • [ ] Record ownership is validated before modifications
  • [ ] Sensitive fields are protected
  • [ ] User input is never trusted for authorization decisions

Error Handling

  • [ ] Unauthorized access returns UnauthorizedAccessResponse()
  • [ ] Security violations are logged
  • [ ] Error messages are helpful but don't expose sensitive info
  • [ ] Failed authorization doesn't leak data

Testing

  • [ ] Tested with guest users
  • [ ] Tested with different security roles
  • [ ] Tested with portal users from different accounts
  • [ ] Tested edge cases (invalid IDs, cross-account access attempts)

Troubleshooting Authorization Issues

Issue: User Gets "Access Denied" Despite Having Permission

Possible Causes:

  1. Security Role doesn't have entity permission configured
  2. Field-level security is restricting access
  3. Sharing filters are blocking access
  4. Record is owned by another account (for portal users)

Solutions:

// Add debugging information
public ActionResponse Details(string id)
{
    SystemInfo.Debug($"User: {UserInfo.Name}");
    SystemInfo.Debug($"Roles: {string.Join(", ", UserInfo.Roles)}");
    SystemInfo.Debug($"IsPortalUser: {UserInfo.IsPortalUser}");
    
    if (UserInfo.IsPortalUser)
        SystemInfo.Debug($"Account: {UserInfo.Account.Name}");
    
    var record = Database.Retrieve<Contact>(id);
    SystemInfo.Debug($"Record AccountId: {record.AccountId}");
    
    return View(record);
}

Issue: Authorization Attribute Not Working

Check:

  1. Is the attribute spelled correctly?
  2. Is the Entity name correct (case-sensitive)?
  3. Does the user's Security Role have the permission configured in Setup?
// Verify entity name matches exactly
// ✅ Correct
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)]

// vs
// ❌ Case matters!
[AuthorizeAction(Entity = "contact", Action = StandardAction.Edit)]

Issue: Portal Users See Wrong Data

Common Cause: Not filtering by Account

Solution:

public override ActionResponse Index()
{
    if (UserInfo.IsPortalUser)
    {
        // ✅ ALWAYS filter by account for portal users
        var data = Database.Query<Opportunity>()
            .Where(o => o.AccountId == UserInfo.Account.Id)
            .ToList();
        
        return View(data);
    }
    else
    {
        // Employees see all
        var data = Database.Query<Opportunity>().ToList();
        return View(data);
    }
}

Advanced Security Patterns

Pattern 1: Action-Specific Security Helper

public class SecureOpportunityController : AspxController
{
    private bool CanUserAccessOpportunity(string opportunityId)
    {
        var opp = Database.Retrieve<Opportunity>(opportunityId);
        
        if (opp == null)
            return false;
        
        // Employees can access all
        if (!UserInfo.IsPortalUser)
            return true;
        
        // Portal users can only access their account's opportunities
        return opp.AccountId == UserInfo.Account.Id;
    }
    
    public ActionResponse Details(string id)
    {
        if (!CanUserAccessOpportunity(id))
            return UnauthorizedAccessResponse();
        
        var opp = Database.Retrieve<Opportunity>(id);
        return View(opp);
    }
    
    public ActionResponse Edit(string id)
    {
        if (!CanUserAccessOpportunity(id))
            return UnauthorizedAccessResponse();

        var opp = Database.Retrieve<Opportunity>(id);
        return View(opp);
    }
}

Pattern 2: Security Context Object

public class SecurityContext
{
    public bool IsEmployee { get; set; }
    public bool IsPortalUser { get; set; }
    public string UserId { get; set; }
    public string AccountId { get; set; }
    public List<string> Roles { get; set; }
    
    public bool HasRole(string role)
    {
        return Roles.Contains(role);
    }
    
    public bool HasAnyRole(params string[] roles)
    {
        return roles.Any(r => Roles.Contains(r));
    }
}

public class SecureController : AspxController
{
    protected SecurityContext GetSecurityContext()
    {
        return new SecurityContext
        {
            IsEmployee = !UserInfo.IsPortalUser,
            IsPortalUser = UserInfo.IsPortalUser,
            UserId = UserInfo.UserId,
            AccountId = UserInfo.IsPortalUser ? UserInfo.Account.Id : null,
            RoleId = UserInfo.RoleId
        };
    }
    
    public ActionResponse MyAction()
    {
        var security = GetSecurityContext();
        
        if (security.IsPortalUser && !security.HasRole("Premium Partner"))
            return UnauthorizedAccessResponse();
        
        return View();
    }
}

Pattern 3: Policy-Based Authorization

public static bool CanApproveExpense(decimal amount, UserInfo userInfo)
{
    var roleName = String.Empty;

    if (userInfo.Role != null)
        roleName = userInfo.Role.Name;
    else
    {
        var role = Database.Retrieve<Role>(userInfo.RoleId);
        roleName = role?.Name;
    }
    
    if (amount < 500)
        return roleName == "Expense Approver" || roleName == "Finance Manager";
    
    if (amount < 2000)
        return roleName == "Finance Manager";
    
    return roleName == "CFO";
}

// Usage in controller:
if (!AuthorizationPolicy.CanApproveExpense(expense.Amount, UserInfo))
    return UnauthorizedAccessResponse();

Pattern 4: Audit Trail for Security Events

public class SecurityAuditLog
{
    public static void LogUnauthorizedAccess(string userId, string action, string resource, string reason)
    {
        var log = new SecurityLog
        {
            UserId = userId,
            Action = action,
            Resource = resource,
            Reason = reason,
            Timestamp = DateTime.Now,
            IpAddress = HttpContext.Current.Request.UserHostAddress
        };
        
        Database.Insert(log);
    }
}

public class SecureController : AspxController
{
    private string GetCurrentUserRoleName()
    {
        if (UserInfo.Role != null)
            return UserInfo.Role.Name;
        
        var role = Database.Retrieve<Role>(UserInfo.RoleId);
        return role?.Name;
    }
    
    public ActionResponse SensitiveAction(string id)
    {
        string roleName = GetCurrentUserRoleName();
        
        if (roleName != "Administrator")
        {
            // Log the unauthorized attempt
            SecurityAuditLog.LogUnauthorizedAccess(
                UserInfo.UserId,
                "SensitiveAction",
                $"Resource ID: {id}",
                "Missing Administrator role"
            );
            
            return UnauthorizedAccessResponse();
        }
        
        return View();
    }
}

Summary

Controller security and authorization in Magentrix provides:

Authentication - Verify users are logged in using SystemInfo.IsGuestUser

Role-Based Authorization - Control access using [Authorize] attribute

Entity Permissions - Enforce CRUD permissions using [AuthorizeAction]

Custom Authorization - Implement complex business rules with manual checks

Account Isolation - Ensure portal users only access their account's data

Multi-Layer Security - Combine attributes with custom logic for comprehensive protection


Quick Reference

Security Attributes

// Require authentication
[Authorize]

// Require specific role(s)
[Authorize(Roles = "Administrator")]
[Authorize(Roles = "Sales Manager,Marketing Manager")]

// Require entity permission
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Read)]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Detail)]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Create)]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit)]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Delete)]

// Custom record ID parameter
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Edit, RecordIdParam = "contactId")]

User Information

Here's the corrected reference:

// Authentication checks
SystemInfo.IsGuestUser           // Not logged in
UserInfo.IsPortalUser            // Partner/Customer user
!UserInfo.IsPortalUser           // Employee user

// User details
UserInfo.Name                    // Full name
UserInfo.Email                   // Email address
UserInfo.UserId                  // User ID
UserInfo.RoleId                  // Role ID (always populated)
UserInfo.Role                    // Role object (may be null - not always loaded)
UserInfo.Role?.Name              // Role name (safe access if loaded)

// Portal user specific (always populated for portal users)
UserInfo.AccountId               // Account ID (always populated)
UserInfo.ContactId               // Contact ID (always populated)
UserInfo.Account                 // Associated Account record (may be null - not always loaded)
UserInfo.Contact                 // Associated Contact record (may be null - not always loaded)

Important Notes:

⚠️ Users have ONE role, not multiple roles

  • Use UserInfo.RoleId to get the role ID (string, always populated)
  • Use UserInfo.Role to access the Role object (may be null)
  • Use UserInfo.Role?.Name to get the role name safely

⚠️ Related objects may not be loaded

  • UserInfo.Account and UserInfo.Contact may be null
  • UserInfo.AccountId and UserInfo.ContactId are always populated for portal users
  • Always use the ID properties for comparisons and filtering

Safe Usage Examples:

// ✅ Safe - Always use ID properties for filtering
if (UserInfo.IsPortalUser)
{
    var opportunities = Database.Query<Opportunity>()
        .Where(o => o.AccountId == UserInfo.AccountId)
        .ToList();
}

// ✅ Safe - Check if Role is loaded before accessing Name
var roleName = String.Empty;

if (UserInfo.Role != null)
    roleName = UserInfo.Role.Name;
else
{
    var role = Database.Retrieve<Role>(UserInfo.RoleId);
    roleName = role?.Name;
}

// ✅ Safe - Load Account if needed
if (UserInfo.IsPortalUser)
{
    var account = UserInfo.Account;

    if (account == null)
        account = Database.Retrieve<Account>(UserInfo.AccountId);
    
    // Use account object
}

// ❌ Unsafe - Don't assume related objects are loaded
if (UserInfo.Account.Name == "Test") // May throw null reference exception
{
    // ...
}

// ❌ Wrong - Users don't have multiple roles
if (UserInfo.Roles.Contains("Administrator")) // Property doesn't exist
{
    // ...
}

Common Patterns

// Check if user is logged in
if (SystemInfo.IsGuestUser)
    return UnauthorizedAccessResponse();

// Check user type
if (UserInfo.IsPortalUser)
{
    // Portal user logic
}
else
{
    // Employee logic
}

// Check role membership
if (UserInfo.Roles.Contains("Administrator"))
{
    // Admin-only logic
}

// Enforce account isolation for portal users
if (UserInfo.IsPortalUser && record.AccountId != UserInfo.Account.Id)
    return UnauthorizedAccessResponse();

Next Steps

Continue your learning journey:


💡 Security is not optional! Always implement proper authorization checks to protect sensitive data and ensure users can only access resources they're permitted to use. Start with restrictive permissions and expand as needed.