When a Personal App Becomes Shared
The first version of an app often has a simple rule: a record belongs to the signed-in user. A project has one user_id, a note has one user_id, and every query asks whether that value matches the current session. That is a good starting point, and the user accounts guide shows how to enforce it.
Teams change the shape of the rule. A project no longer belongs to one person. It belongs to a workspace, several people can be members of that workspace, and different members may be allowed to do different things. The dangerous shortcut is to add an is_admin switch to the profile and hide a few buttons. That switch says something global about a person, while the real question is local: what may this person do inside this workspace, to this particular record, right now?
Authentication proves an identity: "this request came from Sam." Authorization makes a decision: "Sam is an active editor in Workspace A, so Sam may update this Workspace A document." A valid login is only the first fact in that sentence. If your app checks the login but not the workspace and permission, every signed-in user may be one changed URL away from someone else's data.
Model the Workspace Before the Roles
Give shared data an explicit home. Call it a workspace, team, organization, account, or household — the name matters less than having one stable ID that follows every shared record. Then connect people to that workspace through memberships rather than putting a single team field on the user.
A small model is enough for most apps:
- Users represent identities that can sign in.
- Workspaces represent the boundary that owns shared data.
- Memberships connect one user to one workspace and store the role and status for that relationship.
- Shared records carry a
workspace_id, so the app can tell which boundary must be enforced before returning or changing them.
Keep membership unique per user and workspace. Keep invitations separate from active memberships, or give the membership an explicit pending state that cannot access anything. If one user can join several workspaces, never store a single "current workspace" as a permanent truth on the user record. It is a selection for this session or request, not the complete list of places the user belongs.
Adding workspace_id to projects but forgetting comments, attachments, exports, background jobs, or audit events leaves side doors between teams. Sometimes a child record can inherit the boundary through a verified parent relationship; sometimes it should carry the workspace ID directly. Either way, write down how every table reaches its workspace, and reject records whose parent and workspace disagree.
Use a Small Permission Table
Start with actions, not impressive role names. List what the app actually lets people do, then group those actions into the smallest useful set of roles. A typical first version might be:
- Viewer — read workspace content, but cannot change it.
- Editor — create and update ordinary content, but cannot manage access.
- Admin — manage members and workspace settings, but cannot transfer or delete the workspace.
- Owner — perform the few irreversible account-level actions reserved for the person responsible for the workspace.
Write the matrix down before AI writes conditionals. For every action — view a project, edit it, delete it, invite a member, change a role, view billing, delete the workspace — mark which roles may perform it. Treat an unlisted action as denied. This gives you one policy to implement, review, and test instead of role checks scattered through pages and components.
A first version can be this short:
| Action | Viewer | Editor | Admin | Owner |
|---|---|---|---|---|
| View workspace content | Yes | Yes | Yes | Yes |
| Create and edit content | No | Yes | Yes | Yes |
| Delete content | No | Yes | Yes | Yes |
| Invite a member | No | No | Yes | Yes |
| Change a role or remove a member | No | No | Yes | Yes |
| View or change billing | No | No | No | Yes |
| Transfer or delete the workspace | No | No | No | Yes |
Your own rows will differ, and that is the point: the table is a product decision you make once, in plain language, and then hand to AI as the specification. Keep it in the repository next to the code that enforces it, so a later change to the policy and a later change to the checks are the same task.
"Admin" is a convenient label, not the security decision itself. The decision is whether this active membership includes the specific capability required by this operation. Keeping that mapping in one place makes role names changeable and prevents one page from quietly giving an editor powers that another page reserves for admins.
Avoid adding exceptions for individual people until the app truly needs them. A mixture of roles, per-user overrides, plan entitlements, ownership, and feature flags becomes difficult to explain even before AI duplicates the logic. Begin with workspace membership plus a role matrix. Add another dimension only when you can name the user problem it solves and the tests it requires.
Enforce the Boundary Where Data Is Accessed
Hiding the "Delete" button from a viewer is good interface design. It is not a permission check. A user can change a URL, replay a request, call an endpoint directly, or edit values in the browser. The server, backend function, or provider-enforced data layer must make the final decision before any protected data is read or changed.
Each protected operation should establish the same chain of facts:
- The request has a valid signed-in user.
- That user has an active membership in the requested workspace.
- The membership grants the capability needed for this action.
- The requested record belongs to the same workspace.
- The operation returns only data inside that boundary.
Scope database queries by workspace as part of the query itself. Do not fetch a record by ID, send it toward the client, and hope a later check notices that it belongs elsewhere. Apply the same rule to lists, counts, search results, exports, file downloads, webhooks, and background jobs. A beautifully protected project page still leaks if its global search endpoint returns the project title or its export job accepts a workspace ID the caller does not belong to.
Many platforms provide a server credential that can bypass normal data rules. Keep it out of browser code, and do not use it merely because a permission query is inconvenient. A backend using elevated access must perform the complete membership, capability, and workspace check itself before reading or writing. Otherwise the app has replaced a visible policy with an invisible promise.
Invitations, Role Changes, and Leaving
Permissions are not finished when the first member joins. Membership changes are security-sensitive transitions, and each one needs a defined before and after state.
- Invitations: send a one-time, expiring invitation for one workspace and one intended role. Accepting it should require the invited identity, not merely possession of a copied link. Until acceptance, the invitation is not an active membership.
- Role changes: record who changed the role, which workspace it affected, the previous value, and the new value. Re-check permission on the next protected operation rather than trusting a role copied into old browser state.
- Removal: make access stop at the enforcement layer, not just disappear from the team list. Revoke or expire cached workspace access, pending invitations, share links, and any long-running jobs that would continue acting for the removed member.
- Ownership: prevent removal or demotion of the last owner. Transfer ownership deliberately, with a clear confirmation step, before the old owner can leave.
Decide what happens to content created by someone who leaves. In most team apps, the workspace keeps the documents because the workspace owns them; the creator remains attribution, not the access boundary. If your product promises personal ownership instead, make that rule explicit before you build deletion and export flows.
The Prompt to Ask AI
Give AI the current data model and the permission decisions before asking it to edit files. The goal is to make it expose assumptions and produce an enforcement map, not to let it scatter role checks wherever it notices a button.
I want to add teams and permissions to my existing app. Do not implement yet. First inspect the current authentication flow, database schema, API or backend functions, direct browser-to-database access, file storage, search, exports, webhooks, and background jobs. My intended roles are [viewer/editor/admin/owner], and the actions each role may perform are [paste the permission matrix]. Then produce: (1) a workspace and membership data model, including invitation and membership status; (2) an inventory of every shared record and how it is scoped to a workspace; (3) one centralized capability map; (4) the exact enforcement point for every read and write, including provider-enforced data rules; (5) a migration and backfill plan for existing users and records; (6) invitation, role-change, removal, and last-owner behavior; and (7) a test matrix using two workspaces and multiple roles. Default to deny when a permission or workspace is missing. Flag any elevated credential, client-only check, unscoped query, or assumption you cannot prove. Stop after the plan so I can review it.
Review the plan before asking for code. Every protected route and data operation should appear exactly once in the inventory, every shared table should reach a workspace, and every action should map to a capability. If AI says "add middleware" without showing how direct database access, storage, jobs, and exports are covered, the plan is incomplete.
For an app that already has users, insist on a staged migration. Create one personal workspace per existing user, attach their records to it, verify that no shared record is missing a workspace, and only then make the boundary required and turn on enforcement. Read the AI database migration plan before changing a live schema, and take a tested copy first with the backups guide.
Prove It With Two Workspaces
A happy-path test proves that an editor can edit. It does not prove that the editor cannot edit somewhere else. Permission testing needs at least two separate workspaces, because isolation is a relationship between boundaries.
Create these test identities:
- an owner and an editor in Workspace A;
- a viewer in Workspace A;
- an owner in Workspace B;
- a pending invitee; and
- a member who has been removed.
For each identity, test the interface and then repeat the operation directly against the API or provider data layer. Change the workspace ID and record ID in requests. Try a valid Workspace A record ID while authenticated only for Workspace B. Test reads, lists, counts, edits, deletes, file downloads, search, exports, and member management. The correct result is no data and no side effect — not a hidden button, an empty-looking page after protected data briefly loaded, or a successful request whose response the browser chose not to display.
Then test transitions. Accept an expired or already-used invitation. Demote an admin while another browser tab is open. Remove a member while an export or background job is queued. Try to demote the last owner. Confirm that audit events identify the actor and workspace without storing secrets. These are the moments when yesterday's valid permission can survive longer than it should.
Team Permission Checklist
- Every shared record belongs to a workspace directly or through a verified parent relationship.
- Membership is a relationship between a user and a workspace, with an explicit role and status.
- The role matrix is written down and unlisted actions are denied.
- Permissions are enforced where data is accessed, not only by hidden interface controls.
- Every query is scoped to the workspace, including search, counts, exports, files, and background jobs.
- Elevated credentials stay server-side and require equivalent application checks.
- Invitations expire and are single-use; pending invitations grant no access.
- Role changes and removals take effect at the boundary, including cached and queued work.
- The last owner cannot disappear accidentally.
- Two-workspace tests cover direct requests, not only what the interface shows.
- Existing data is backfilled and verified before workspace enforcement becomes mandatory.
Related Guides
Adding User Accounts to Your Vibe Coded App
Add login without building your own password system, enforce per-user access, and test the complete account flow before launch.
Adding File Uploads to Your Vibe Coded App
Use hosted storage, validate what you received, and keep private files inside the same access boundary as the records they belong to.
Adding Backups to Your Vibe Coded App
Protect the database and uploaded files before AI changes a live schema or backfills existing records.
The AI Database Migration Plan
Change a live schema with compatibility, backfill evidence, staged enforcement, and a recovery path.