Bootcamp
Plan access control with a privacy-rule table
Photo by engin akyurt on Unsplash
Access control is the part of an app you least want to retrofit. Bolt it on after launch and every workflow, search and field becomes a suspect; design it up front and it’s a handful of rules that fall out of a table you can draw in ten minutes.
This walkthrough takes a single-tenant CRM, where every user can see every record, and turns it into a proper multi-tenant app with accounts, team roles, a create-team-member flow, and deals restricted to the user they’re assigned to. The build follows a planning method that works on any project, in any stack.
A privacy-rule table is a permissions matrix: user roles along the top, data types down the side, and in each cell one short sentence stating what that role may do with that data. It documents the intention of your access model so you can implement it, and later audit it, against something written down.
Start with the data model, on paper
The brief for the CRM’s extension: a team members screen with the ability to add a new team member, view them, set them inactive and reset their passwords; the ability to update the account’s name; and the ability to assign deals to assignees via a dropdown.
Before touching the Bubble editor, update the technical documentation: the UML entity diagram, the database diagram and the schema. Copy the existing documentation rather than destroying it, label the copy as the extension, and add what’s new:
- Account becomes a new entity, with a name field. One account has zero-to-many contacts, zero-to-many deals, and one-or-many users (an account can’t really exist without at least one user).
- User role and user status are option sets, not entities: role is Owner or Team Member, status is Active or Inactive. A user has one role and one status; each option can apply to many users. Status will help control access later: deactivate a user and they can’t use the system.
- Contact, Deal and User each gain an
accountfield. These are the foreign keys the privacy rules will hinge on. - Deal also gains an
assigneefield of type User.
Two modelling decisions deserve a comment. First, even though a deal’s contact already knows its account, put account on the deal as well: privacy rules can only reference the record itself and the current user, so the field you compare against must sit directly on the data type. Duplicating the key makes the rule references easy. Second, that same constraint is the reason this planning step exists at all: the rule you’ll want is “this contact’s account equals the current user’s account”, so both sides of that comparison must exist as fields before any rule can be written.
Draw the privacy-rule table
Now document the access model. Columns: the three roles under consideration, account owner, team member and logged out. Rows: every data type, listed alphabetically so nothing gets missed: account, contact, deal, profile, tag, user.
Fill in the cells with short sentences:
- Logged out: none, for every data type. No exceptions.
- Team member: their own account’s details, and all contacts, deals, profiles, tags and users within their account.
- Account owner: at this stage, the same: everything within the account.
That’s deliberately simple: every user sees everything inside their own account and nothing in anyone else’s, and we’ll come back to differentiate owner from team member at the end. The point is that the whole access model now fits on one small table anyone can review.
Update the database
With the plan on paper, the Bubble changes are mechanical:
- Update the user role option set to two options:
OwnerandTeam Member. On the User type, default therolefield to Owner, so every new signup is an owner unless you specify otherwise. - Create a user status option set with
ActiveandInactive. Add astatusfield on User, defaulting to Active. - Add
first nameandlast nametext fields to User to match the designs. - Create the Account data type with a
namefield (add a logo field too if you’re feeling clever). Then add anaccountfield of type Account on User. - Update the signup form: add a mandatory
account nametext input to the sign-up group, placed at the top. - Edit the signup workflow. After
Sign the user up, create a new Account with name = the account-name input’s value, then make changes to the user, attaching their profile and the new account from the results of the earlier steps. Defaults handle the rest: role Owner, status Active. - Add a comment on that workflow step, something like “IMPORTANT: only set this field during initialisation”. Changing a user’s account later would silently change what data they can access, so flag it for future developers.
- Search the app for every
Sign the user upaction and confirm there’s exactly one. Signup logic that exists in one place can’t drift out of sync or duplicate accounts.
Test it: sign up with a new account name, confirm the email, then check App data. Set Account’s primary field to name so the linkage is readable, and verify the new user carries the account, the Owner role, Active status and a profile. One problem remains, though: the new user can still see all the contacts created by other users. Time for the rules.
Implement the privacy rules
Work through the table row by row. The pattern is the same each time: grant access when the accounts match, and grant Everyone else nothing.
- Account. Define a rule (call it
account user): whenCurrent User's Account is This Account, grant view. Everyone else: nothing. - Verify it before moving on. On a test page, add a repeating group searching for Accounts showing each account’s name, plus texts showing
Current User's email,Current User is logged in,Current User's Role's Display,Current User's Account's Nameand the repeating group’sList of Accounts:count. Create a second account in App data and refresh: a user in the first account sees exactly one row, their own, and the second account never appears. Log out and the count drops to zero. Cheap tests like this make what’s going on obvious, and really understanding what’s going on is most of the job. - Contact. Add the missing
accountfield to Contact first; without it there’s nothing to reference. Then the rule: whenCurrent User's Account is This Contact's Account, grant everything, keeping auto-binding enabled on the status field, and revoke everything for Everyone else. Existing contacts have no account yet, so they correctly vanish; set one contact’s account in App data and it reappears for that account’s users only. - Deal. Add the
accountfield to Deal, then the same rule: whenThis Deal's Account is Current User's Account, grant everything; Everyone else, nothing. Assign a couple of deals to the account in App data and confirm they show. - Profile. The profile is an extension of the user, and it needs its own
accountfield too. The rule: whenCurrent User's Account is This Profile's Account. Note the shape: not “this profile belongs to the current user”, because users must be able to see all the profiles within their account, not just their own. - User. Same row-level idea: when
Current User's Account is This User's Account. If Momentum has three users, anyone in Momentum can see those three users and nobody else’s. - Tag. Give Tag a
nameand anaccountfield so tags aren’t shared across accounts, and apply the identical rule.
That’s the SaaS model applied: anyone within an account can see all of that account’s data; anyone outside it, or logged out, sees none of it.
Build the create-team-member flow
The designed UI for team members looks a lot like the contacts screen, and by this point in a build you can construct that yourself. The functional core is what matters, so prove it on a rough page first:
- Create a page called
team member POC. Add an “Add new team member” button that shows a popup titled “New team member”. - In the popup, add inputs for first name, last name and email. Make the email input mandatory and format it as an email, and put it last so labels and tab order read sensibly.
- On the button inside the popup, add the action Create an account for someone else, with email = the email input’s value.
- In the same action, set the new user’s fields explicitly:
account = Current User's Account,role = Team Member, plus first name and last name from the inputs. Both assignments are critical: the account field is what grants them access to this account’s data, and without the role override they’d default to Owner. - Close the popup and reset the inputs.
- Add a repeating group of users:
Do a search for Userswith no account constraint at all, showing email and role. The privacy rule on User takes care of the scoping, which is exactly the point: the front end no longer needs to remember to filter. - Test it. Create a team member using plus-addressing (yourname+tm1@… arrives in the same Gmail inbox, which makes multi-user testing painless). App data now shows the account with one owner and one team member, and the repeating group shows both; a user belonging to another account never appears.
- New team members can’t log in yet, so add a “send temp password” action per row: Assign a temporary password to the current cell’s user, then send an email to the current cell’s user’s email containing the result of step one and a login link to the site’s home URL, plus a toast confirming it sent. Make the button not clickable when the current cell’s user is the current user, because emailing yourself a temporary password resets your own login for no reason. (Emailing temp passwords is a rudimentary approach; showing it once on screen for the admin to pass on works too.)
- Test the whole loop in an incognito window: open the emailed link, log in with the temporary password, and confirm the new team member sees the account’s contact and the other team members, but has no reset button on their own row.
Restrict deals to their assigned user
Finally, differentiate the roles. Copy the privacy-rule table so the original stays intact, and change one cell: team members should now see only assigned deals. Then implement it:
- On Deal, create an
assigned userfield of type User. - Replace the Deal privacy rule with two. Rule one,
account owner: whenCurrent User's Account is This Deal's AccountandCurrent User's Role is Owner, grant everything. - Rule two,
account team member: whenCurrent User's Account is This Deal's AccountandCurrent User's Role is Team MemberandThis Deal's Assigned User is Current User, grant access. A team member with no assigned deals now has no deal access at all; owners see everything, and in practice it’s the owner doing the assigning. - Test without building the assignment UI: in App data, set deal CC’s assigned user to team member two. Log in as each user in turn: the owner sees both deals, team member one sees none, team member two sees exactly deal CC.
- Then wire the real UI: the assignee dropdown on the deal editor writes to
assigned user, and the deal card’s label shows the assigned user’s details.
The subtle change is the interesting part. By compounding conditions (account and role and assignment) you can build very granular permissions from simple pieces. Scale the same idea up: an account representing a large company, divisions within it, departments within those, and rules that let each layer see only its own data while senior levels see it all. That is the power of privacy rules.
Try it yourself
Do this for your own project before writing a single rule: update your schema, UML and entity documentation, then draw the privacy-rule table with every role and every data type. If a cell is hard to fill in with one short sentence, your access model isn’t decided yet, and it’s far cheaper to decide it in the table than in production.
This skill in the AI era
The privacy-rule table might be the single most portable artefact in this whole course. Roles down one axis, data down the other, intent in every cell: hand that to Claude Code and it becomes row-level security policies, middleware checks and test cases in whatever stack you’re building on. What AI can’t do is decide who should see what; that’s a business decision, and the table is how you make it legible. It’s the working method behind our move from Bubble to AI-assisted development, and the kind of planning we bring to every AI-assisted build.
If this sparked something, let's talk.
No pitch, no pressure — just a conversation about what you're working on.
Let's talkRelated posts
Bootcamp
A simple approach to data modelling
A four-step method for designing a database schema: identify entities, map relationships, choose fields. Taught through the CRM data model built in Bubble.
Bootcamp
Module 5 — Application architecture: pages, states and reusable pieces
Single-page vs multi-page apps, URL parameters, custom states, reusable elements — and a big practical building a main template with an expanding navigation drawer.
Bootcamp
API calls as actions vs data (and the planning checklist)
When to use Bubble API calls as actions versus data sources, a one-to-many auth pattern, and the planning checklist that keeps integrations from failing silently.