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
- Security Overview
- Authentication Basics
- Authorization with Attributes
- Entity-Level Permissions
- Role-Based Authorization
- Custom Authorization Logic
- Practical Security Examples
- Best Practices
- Common Security Pitfalls
- Security Checklist
- Troubleshooting Authorization Issues
- Advanced Security Patterns
- Summary
- Quick Reference
- 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
| StandardAction | Description |
|---|
StandardAction.Read | Check if user can view entity records (list-level) |
StandardAction.Detail | Check if user can view a specific record |
StandardAction.Create | Check if user can create new records |
StandardAction.Edit | Check if user can edit a specific record |
StandardAction.Delete | Check 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 entityDatabase.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:
- Extracts the
id parameter from the URL - Checks if the user has permission to view that specific Contact record
- 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 entityDatabase.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:
- Security Role doesn't have entity permission configured
- Field-level security is restricting access
- Sharing filters are blocking access
- 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:
- Is the attribute spelled correctly?
- Is the Entity name correct (case-sensitive)?
- 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 nullUserInfo.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.