Start with the decisions the product intends to allow
An authenticated user can still be unauthorized to perform an action. A tenant owner may manage their own workspace while having no access to another customer's records. A role label needs both a defined permission set and a resource boundary.
Our example policy has four roles, six actions and two tenant relationships. That produces 48 decisions: 4 × 6 × 2. The Python model allows 11 and denies 37. All 24 requests targeting another tenant are denied, including requests from an owner.
| Role within the matching tenant | Read | Edit | Invite | Export | Billing | Delete |
|---|---|---|---|---|---|---|
| Owner | Allow | Allow | Allow | Allow | Allow | Allow |
| Editor | Allow | Allow | Deny | Deny | Deny | Deny |
| Viewer | Allow | Deny | Deny | Deny | Deny | Deny |
| Billing | Allow | Deny | Deny | Deny | Allow | Deny |
These are proposed product rules, not a standard permission hierarchy. In particular, export belongs only to the owner in this example, and the billing role can read and manage billing. A different SaaS product may make different choices.
Each role has 12 tested decisions: six actions in its own tenant and the same six against another tenant. The chart therefore includes cross-tenant denials as well as denials caused by a missing action permission. A high denial count is not a security score.
Separate identity, membership and resource scope
Authentication establishes an identity under the application's chosen mechanism. Authorization determines whether that identity may perform the requested action against the particular target. Tenant membership supplies part of the context for that decision.
AWS's SaaS architecture guidance distinguishes authentication and authorization from tenant isolation: a user can be authenticated and authorized at one level while still reaching another tenant's resources if the isolation boundary is missing. AWS SaaS tenant isolation.
In the model, the caller has an active membership in tenant-a. The target belongs either to tenant-a or tenant-b. The authorization function first requires authentication and an active membership, then compares the two tenant identities before checking the action permission.
Those input values are supplied directly by the fixture. In an application, the server needs trustworthy evidence for the active membership, its role and the target's tenant. A request body claiming that the caller is an owner in the target tenant cannot supply that evidence by itself.
A person can belong to several workspaces. Resolve the membership relevant to the requested workspace rather than treating one role as a global property of that person. Switching workspace context should change which membership participates in the decision.
Require an explicit permission after the tenant check
The owner role in this policy grants six actions within a matching tenant. It does not bypass the tenant comparison. An owner request against tenant-b reaches the mismatch branch and is denied before the action set is inspected.
For a matching tenant, the action must be known and present in the role's permission set. Unknown roles and unknown actions return a denial. The function has one final allow path after all required conditions pass.
OWASP recommends least privilege, denial by default and permission validation on every request. Its guidance also explains why object identifiers alone cannot establish access rights. OWASP authorization guidance.
The sequence is authentication, active membership, matching resource tenant, explicit action permission and then allow. A failed condition ends with denial. The diagram describes the sample policy; it does not prescribe one framework or middleware arrangement.
A predictable record ID may make an authorization defect easier to exploit, but an unguessable ID does not repair the missing decision. After locating a record, verify access to that specific target under the current request context.
Make the matrix expose policy disagreements
A permission table is useful before implementation because it turns ambiguous role names into reviewable decisions. Ask what an editor can change, whether a viewer can export and who can invite another owner. The answers should be product decisions that the code preserves.
The sample deliberately keeps the action set small. Its delete action is an abstract tenant-scoped capability, not a complete account-deletion workflow. Additional conditions such as ownership of a document, approval status or recent authentication are absent and must not be inferred from an allow cell.
Roles can become awkward when resource relationships and attributes carry the real rule. OWASP discusses attribute- and relationship-based approaches for richer policies. Our example already uses a tenant attribute alongside role permissions, but it does not model arbitrary sharing, delegation or organizational hierarchies.
If a document can be shared across tenants, the blanket cross-tenant denial rule no longer describes the complete product. Introduce an explicit sharing relationship and test its lifecycle rather than adding a hidden owner bypass. Expiration, revocation and inherited access then become part of the policy surface.
Treat each new permission as a change to both the allow and deny expectations. An added role should not inherit all capabilities because an unknown-role fallback happened to be permissive.
Exercise the negative paths outside the main grid
The 48-case matrix covers known roles and actions with an authenticated, active membership. Five additional cases test unknown role, unknown action, unauthenticated caller, missing tenant context and revoked membership. All five are denied. They are separate from the 48 decisions in the title.
| Additional input | Model outcome | Reason to retain the case |
|---|---|---|
| Unknown role | Deny | A new or malformed role must not gain a default privilege |
| Unknown action | Deny | Unrecognized capability names must not bypass the policy |
| Unauthenticated caller | Deny | A role value alone is insufficient identity evidence |
| Missing tenant context | Deny | An absent scope must not turn into a global query |
| Revoked membership | Deny | Previously valid access is not automatically current access |
These cases pass trusted values directly into a pure function. They do not test a session parser, token validation, route wiring or database query. Integration tests must demonstrate that the real request reaches the policy with the intended inputs and that a denial prevents the underlying operation.
A denial should also leave protected state unchanged. For a write endpoint, inspect the resulting business record or side effect, not just the response status. For a download, verify that the file cannot be retrieved through a second unprotected URL.
Keep enforcement attached to every access path
Hiding a button improves the interface but does not enforce a server-side permission. The same action may be reachable through an API, bulk operation, background job or export endpoint. Enumerate those paths when translating the matrix into an implementation test plan.
List queries need tenant and policy constraints before returning records. Filtering an unauthorized item out of the visible page is insufficient if counts, search suggestions or exported rows still reveal it. A database filter and an action-level check can address different parts of the access contract.
Pagination is one example of context that must survive repeated requests. A cursor should not drop the tenant or authorization scope on the next page. Our cursor pagination experiment shows how continuation state interacts with changing data; authorization must be applied independently on each request.
Background work requires an explicit authority model too. Decide whether a job acts under a captured user permission, a current membership check or a narrowly scoped service identity. Revocation behavior differs between those choices. The small function in the download does not decide which policy your product needs.
Decide how permission changes reach cached decisions
Revoking a membership in the authoritative store does not necessarily remove an old decision from every cache. Define whether sensitive actions re-evaluate current membership and how other paths learn that a cached permission has changed.
A cached decision may depend on user, tenant, role, resource and action, as well as a policy version. Omitting an input from the key can reuse an allow result in the wrong context. Expiration alone also leaves a period during which old state may remain usable.
Our cache-aside stale-fill model demonstrates why invalidation can be followed by an obsolete refill. Its generation check protects one modeled cache entry; it does not prove that an authorization system handles revocation correctly.
Include revocation while a request is in flight in the product's consistency decisions. Some operations may require checking at the moment a durable change commits. Others may use a documented short-lived capability. State the intended behavior and test the actual boundary rather than assuming every permission check has the same lifetime.
Run the matrix and connect it to real endpoints
Download the Python policy, 48-decision CSV and complete results with five extra cases. Save the source as experiment.py and execute it with Python 3.
python3 experiment.pyThe standard-library script compares every matrix result with the declared product expectations, checks all cross-tenant denials and repeats the run to confirm deterministic output. It asserts the total of 11 allows and 37 denials, then evaluates the additional negative inputs separately.
Use the CSV as a review artifact for product and engineering. Replace the example permissions with agreed rules, add the missing resource relationships and map each action to its real entry points. The resulting integration evidence should show both permitted behavior and the absence of protected effects after denial. Passing this finite model demonstrates agreement with its stated policy, not the security of a deployed SaaS application.
Sources
Documentation checked .
