An approval app I worked on has a diagnostic panel that only an admin can see, and even an admin can’t just navigate to it. It unlocks by clicking an invisible fifty-pixel rectangle in the corner of the home screen nine times. varUserIsAdmin gates it, a click counter unlocks it, and neither one alone is enough. That’s a throwaway easter egg, but it’s also the whole idea behind every access rule in the app: knowing who someone is isn’t the same as knowing what they’re allowed to do right now, and the interesting formulas are the ones that check both.
Role alone isn’t enough
Most role-gating examples stop at Visible = varIsAdmin. That works for an admin-only screen, where the role never changes what “allowed” means. It falls apart the moment the app has an approval workflow, because who’s allowed to touch a field changes as the record moves through statuses. A manager can edit their section while the request is in their queue. Once they approve it and it moves to the next stage, that same manager touching the same field should no longer be able to, even though their role hasn’t changed at all. Gating on role alone is either too permissive, letting an approver edit after their stage has closed, or too restrictive, locking the requestor out of fixing a typo before anyone has reviewed anything yet. The fix is gating on role and workflow status together, checked in the same formula, not as two separate rules that happen to both be true.
Role detection: one list, cached, boolean flags
The pattern starts with a single flat list: one row per person per role, an Employee column and a Group column. Load it once, at app start, into a collection:
ClearCollect(membersCollection, 'App - Members');
Then derive a boolean flag per role with a LookUp against that cached collection, not the live list:
Set(
varUserIsAdmin,
Not(
IsBlank(
LookUp(
membersCollection,
Lower(Employee.Email) = Lower(varUserEmail) && Group.Value = "Admins"
)
)
)
);
Set(
varUserIsITReviewer,
Not(
IsBlank(
LookUp(
membersCollection,
Lower(Employee.Email) = Lower(varUserEmail) && Group.Value = "IT Reviewers"
)
)
)
);
Not(IsBlank(LookUp(...))) reads oddly the first time you see it, but it’s doing exactly what it looks like: try to find a matching row, and the flag is true if one exists. The detail that silently breaks this if you skip it is Lower() on both sides of the email comparison. Email addresses round-trip through different casing depending on where they came from, Azure AD, a SharePoint person field, a manually typed value, and a flag built without the lowercase wrap will work in testing (where you typed your own email a dozen times, always the same way) and then intermittently fail for other users whose email happens to arrive with different casing.
Each role is its own flag, computed once per session rather than re-evaluated inline everywhere it’s needed. That’s a deliberate tradeoff: it means every screen and field can reference varUserIsITReviewer directly instead of repeating the LookUp formula, at the cost of the flags being stale if group membership changes mid-session. For an approval app where a session is a single sitting, that tradeoff is the right one.
A role that isn’t just one list
Not every role reduces cleanly to “look up this person in this list.” One flag in this pattern, budget reviewer, is true if the person matches a group in the Members list or shows up in a completely separate list of financial analysts pulled off a different data source:
Set(
varUserIsBudget,
Not(
IsBlank(
LookUp(
membersCollection,
Lower(Employee.Email) = Lower(varUserEmail) && (Group.Value = "Budget Reviewers" || Group.Value = "CIP Reviewers")
)
)
) || Not(
IsBlank(
LookUp(
financialAnalystCollection,
Lower(Email) = Lower(varUserEmail)
)
)
)
);
The lesson isn’t the specific formula, it’s that a role flag is allowed to be a compound check across more than one source. Treating “is this person authorized” as always reducible to a single list lookup is the assumption that breaks first when a real org’s access model doesn’t fit neatly into one table.
Whose role, not just the current user’s
Everything above answers “what can the person looking at the screen do.” A different question shows up when a record needs to know something about someone other than the current user, most often the requestor. When an approval record loads, the app checks whether the person who submitted it belongs to a specific reviewing group, to decide which review path the record follows:
Set(
varRequestorIsIT,
Not(
IsBlank(
LookUp(
membersCollection,
Group.Value = "IT Reviewers" && Employee.Email = varRecord.Requestor
)
)
)
);
Same LookUp against the same cached collection, just keyed on a different email. It’s easy to write role-detection code that only ever asks “am I allowed,” and miss that the identical technique answers “is this other person a member of that group” just as well, which turns out to be exactly what routing logic needs.
DisplayMode, not Visible
This is the part that actually changes behavior, not just organizes it. Visible = false controls whether a user can see that a field exists at all. DisplayMode.Disabled (or .View) controls whether they can change it, while still showing them the value. For an approval workflow these are not interchangeable. A reviewer needs to see the fields a requestor filled in even during the stage where they can’t edit them, both because rejecting something you can’t read is useless and because a field that vanishes and reappears as the record moves through statuses reads as a bug, not a feature.
The real formula, generalized:
DisplayMode: =If(
Or(
ThisItem.'Request Status'.Value = "Draft",
ThisItem.'Request Status'.Value = "Corrections Needed",
And(
ThisItem.'Request Status'.Value = "Pending Manager Review",
Or(
varUserEmail = Lower(ThisItem.Approver),
varUserEmail = Lower(ThisItem.'Approver Alternate'),
varUserEmail = Lower(ComboApproverOverride.Selected.UserPrincipalName),
varUserEmail in Lower(ThisItem.'Additional Approver Emails')
)
)
),
DisplayMode.Edit,
DisplayMode.Disabled
)
Read outward in: the field is editable if the record is still in draft or bounced back for corrections (the requestor’s own stages), or if the record is specifically waiting on a manager’s review and the current user is that manager. Everyone else, at every other status, sees the value locked. Nothing about this needs Visible at all. The field is always there; whether it accepts input is the only thing that changes.
Why this works: a reviewer opening a record mid-workflow sees the exact same layout a requestor does, values and all, just locked. Nothing shifts position or disappears depending on who’s looking at it, which is what makes the form trustworthy to read even when you can’t edit it.
Delegated approval: more than one person can be “the approver”
The Or(...) inside that manager check is worth pulling apart on its own, because it’s the realistic shape of “who can approve this,” not the simplified version most examples show. Four different things independently qualify someone as the approver for this stage:
- The record’s stored primary approver (
Approver) - A separate reviewer-override field, for when the primary approver has explicitly delegated
- A live combo box on the screen itself, letting an admin reassign the approver on the spot
- An “Additional Approver Emails” list, for cases with more than one valid alternate
Any real org has a version of this problem: the person who’s supposed to approve something is out, or the assignment was wrong, or more than one person is legitimately allowed to sign off. A single hardcoded varUserEmail = ThisItem.Approver check works in the demo and fails the first time someone goes on vacation. Building the delegation paths into the same OR chain as the primary check, rather than as a separate escape hatch, means every field that checks “am I the approver” gets delegation for free instead of needing it bolted on later.
Composite status without a new column
One more piece worth knowing before you copy this pattern: some records need concurrent approval from more than one reviewer type at once, IT and Facilities both have to sign off, for example, and neither can be assumed to happen first. Rather than adding a second status column to track that, the status value itself carries a delimiter:
"Pending IT & Facilities Review"
And anywhere the app needs to know “is this record in one of the composite concurrent-approval states,” the check is a plain substring test:
And("&" in ThisItem.'Request Status'.Value, varUserIsBudget)
It’s a small trick, but it avoids a second boolean column that has to be kept manually in sync with the status text every time a workflow stage changes. The status string is already the single source of truth for where a record is; encoding “this stage needs more than one approver” as a character inside that same string means there’s nothing else that can drift out of sync with it.
The maintainability cost, and the fix
Here’s the part worth being honest about instead of glossing over: the DisplayMode formula above isn’t defined once. It’s pasted, nearly identically, onto every field on the form that needs the same gating, which in a form with dozens of fields means dozens of copies of the same Or(And(Or(...))) chain. That works, and it’s exactly what shipped, but it has an obvious failure mode: change the business rule (a new status name, a new delegation path) and it has to change identically in every copy. Miss one, and that single field silently keeps the old rule while every other field on the form has the new one. Nothing errors. The form just becomes subtly inconsistent, and the only way to find it is to test every field at every status, which nobody does after the first release.
The fix is the same idea covered in the variables and named formulas guide already on this site: compute the gate once, as a named formula or a single boolean set when the record loads, and reference that everywhere instead of re-deriving it per field.
// Named formula, computed once
CanEditAsApprover = And(
varRecord.'Request Status' = "Pending Manager Review",
Or(
varUserEmail = Lower(varRecord.Approver),
varUserEmail = Lower(varRecord.'Approver Alternate'),
varUserEmail in Lower(varRecord.'Additional Approver Emails')
)
)
Every field’s DisplayMode becomes If(Or(varIsDraftOrCorrections, CanEditAsApprover), DisplayMode.Edit, DisplayMode.Disabled). Change the rule once, and every field that references it changes with it. This is the difference between an access model that’s correct on day one and one that stays correct after the tenth change request.
Real-world impact: a new approval stage or a changed delegation rule becomes a one-line edit to the named formula instead of a find-and-replace across dozens of fields, hoping the search caught every copy.
Testing checklist
- Log in as each role and step through every status the record can be in, not just the one the role is “supposed” to see.
- Have a field open in edit mode, then have another session move the record to the next status. Confirm the field locks on next load rather than staying editable from a stale session.
- Confirm the delegation chain actually admits an alternate approver, not just the primary. This is the check that’s easiest to skip because the happy path (primary approver approves their own stage) never exercises it.
- Force a composite ("&") status and confirm every component role it’s supposed to unlock for actually gets edit access, not just the first one tested.
Key Takeaways
- Gate on role and status together, in the same formula. Either one alone is either too permissive or too restrictive once a workflow has more than one stage.
- Cache the membership list once and reduce it to boolean flags with case-insensitive
LookUpchecks. The lowercase wrap on both sides of the email comparison is the detail that breaks silently if skipped. - Use
DisplayMode, notVisible, to lock a field a reviewer still needs to read. Hiding and disabling answer different questions, and approval workflows need the second one far more often than the first. - Build delegation into the approver check from the start: a primary field, an override field, and an additional-approvers list, all OR’d together, not a single hardcoded email.
- A status string can carry more than a name. A delimiter substring check for composite, concurrent-approval states avoids a second column that has to be kept in sync by hand.
- The real cost of this pattern is duplication. Compute the gate once as a named formula, not once per field, or a single missed copy becomes a silent, hard-to-find inconsistency.
Pick one field in an existing approval app and trace its DisplayMode formula back through every status and role it depends on. If that takes more than a minute, or the same logic is pasted onto a dozen other fields with no single source of truth, that’s the field to fix first.
Discussion
Loading comments...
Leave a comment
Your email is required but will never be displayed publicly.