Follow the account contract
Arc CMS owns accounts, sign-in, roles and payments. Your app, built on a copy of Arc CMS or running elsewhere against the same Firebase project, relies on the few rules on this page and keeps to its side of them.
One person, one account
A person is one Firebase Auth account plus one document in the users collection. The document id is not the Auth uid: the document holds the uid in its uid field. Roles and the admin screens for them are in Users and roles; how the role reaches the token is in Roles and claims.
| You need | Use |
|---|---|
| The signed-in person's record id | The arccms_uid claim in their ID token |
| Their record | users/{arccms_uid}, readable by its owner and by admins |
| Their Arc CMS role | The arccms_role claim (isAdmin() and isEditor() in rules) |
| Their plan | The premium fields on the record: Add payments and premium features |
| Their private data | users/{userDocId}/... in Firestore and in Storage |
| Clean up data kept elsewhere | A trigger on users/{docId} deletes, or the user.deleted event |
| To know when a sign-in was deleted but its record was not | The user.signInDeleted event (below) |
| An account for someone with no email and no phone | createAppAccount() from the app kit: App accounts. Its record has email and phone set to '' and by: 'app', and the person cannot change or delete it (Locked by default) |
Read the record id from the token
Every account carries the custom claim arccms_uid, whose value is its users document id. Read it from the ID token instead of querying:
const token = await auth.currentUser.getIdTokenResult();
const userDocId = token.claims['arccms_uid'] as string; // users/{userDocId}
In security rules, ownsUserRecord(userDocId) compares the claim with no document read.
When the claim is set
- At sign-up, on every path: email, phone, Google, and an admin adding a user. The server sets it before the browser gets its first ID token.
- At sign-in, the browser compares the token with the record. If
arccms_uidorarccms_roleis missing or differs, it calls therefreshMyClaimscallable and refreshes the token before the app is told the person is signed in. - When a record moves to another sign-in account, the claims follow it and the old account loses its Arc CMS claims.
- When a record is blocked (
isActive: false) or detached, it has no Arc CMS claims and its sessions are ended, so access stops within the hour. Unblocking puts the claims back. - An admin can re-apply every account's claims from the records with the
syncAllUserRolescallable.
A token without the claim owns nothing. ownsUserRecord() is false, so rules deny. Treat a missing claim the same way in app code.
Keep per-person data under the record
| Where | Path | Who can use it |
|---|---|---|
| Firestore | users/{userDocId}/{collection}/..., at any depth | Nobody but the Admin SDK until your app grants access in firestore.app.rules |
| Storage | users/{userDocId}/..., or under the install's upload folder, such as arccms/users/{userDocId}/... | The owner and admins can read; your app grants writes, with its own size and type limits, in storage.app.rules. Never public. |
A minimal Firestore grant:
// firestore.app.rules
match /users/{userDocId}/progress/{lessonId} {
allow read, write: if ownsUserRecord(userDocId);
allow read: if isAdmin();
}
Writing and testing these files is in Set security rules and indexes.
What deleting an account removes
An admin deletes an account under Users, or the person deletes their own under Profile. Deleting your own account needs a sign-in within the last 10 minutes, and an admin's account can only be removed by another admin. Either way the users document is deleted, and then the server:
Ends access first: removes the Arc CMS claims and ends every session, so a failure later never leaves a removed admin an admin.
Firestore: deletes every subcollection under
users/{userDocId}at any depth, the person's in-app notifications (Notifications) and their feedback (Feedback).Storage: deletes the folder
users/{userDocId}/(under the upload folder, if the install has one) and the profile photos inavatars/{uid}/.Sign-in: deletes the Firebase Auth account, the email lookup entry, the phone number index entry and the PIN, and your app's PINs and its gates' counts of wrong phone PINs (see PINs for your app). The Auth account is kept when the record's
authOwnerishostorshared(another app owns or shares that login); it then only loses the Arc CMS claims.Event: emits
user.deletedon the event bus (AppEvents), withuserId(the Auth uid) anddata.userDocId, once every step above has worked.
A failed step makes the whole cleanup run again, for up to an hour after the delete. Every step is safe to repeat.
The person's contact: deleting your own account also erases the contact for your address (its lists, consent and form sign-ups), with a receipt in ErasureLog that holds no address. An admin deleting a user only unlinks the contact, since the address may have been on a list before the account existed.
When a sign-in is deleted some other way
Arc CMS deletes the users record first and the sign-in after it. A sign-in can also be deleted without Arc CMS: by the person's browser (auth.currentUser.delete(), which Firebase allows any signed-in account) or by another app sharing the Firebase project. Arc CMS then keeps the record, deletes nothing and emits user.signInDeleted, with userId (the Auth uid), data.userDocId, and data.locked (whether it is a locked app account). user.deleted is not emitted, because the account is not deleted. The event fires once per deletion, and never for Arc CMS's own deletions.
What to do is your app's choice. For an app account, give the sign-in back with restoreAppSignIn() or delete the account with deleteAppAccount() (see App accounts). For anyone else, an admin can delete the record under Users.
Data your app keeps elsewhere
Anything outside the record (another collection, another service) is yours to delete. The simplest way is a trigger of your own on the same delete, in functions/src/custom/index.ts:
import { onDocumentDeleted } from 'firebase-functions/v2/firestore';
import { db } from '../init.js';
import { arcDocument } from '../arc-config.js';
export const onUserRecordDeleted = onDocumentDeleted(arcDocument('users/{docId}'), async (event) => {
const snap = await db.collection('Orders').where('userDocId', '==', event.params.docId).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
});
It deploys as arccms-custom-onUserRecordDeleted and may run more than once, so keep it safe to repeat. See Write your own Cloud Functions. To act only after Arc CMS's own cleanup has finished, react to the user.deleted event instead.
What your app may and may not write
| Your app may | Your app must not |
|---|---|
Write its own data under users/{userDocId}/, with its own rules | Create users documents from the browser. Only admins may; every sign-up creates its record on the server. |
| Let the person edit their own record's other fields | Write role, uid, isActive, status, authOwner, email, phone, phoneVerified, or any premium or credit field. The rules refuse these from the owner; the server writes them. |
Give its own users a plain role claim. Arc CMS never reads or writes role, so it grants no Arc CMS access. | Name a claim arccms_ anything |
Set claims of its own with mergeAppClaims() from the app kit, which merges them with the existing ones (see App accounts) | Replace all claims. Arc CMS always merges; setCustomUserClaims with only your keys wipes arccms_role and arccms_uid. |
An app that sets claims with setCustomUserClaims(uid, { plan: 'pro' }) removes the Arc CMS claims from that account: its rules then deny the person their own record, and an admin loses the admin area until the Arc CMS app next loads and puts the claims back. Use mergeAppClaims() instead: it merges, and takes turns with Arc CMS's own claims writes on the same account. Reading the claims yourself and writing them all back keeps Arc CMS's claims, but can still lose a write Arc CMS makes at the same moment.
An app outside this repository
An app or mobile client in the same Firebase project signs people in with the same Firebase Auth, then reads arccms_uid as above. If the claim is missing, it calls arccms-refreshMyClaims with no input and refreshes the token; the reply is { arccms_uid }, or an error when the login has no Arc CMS record or is blocked. When Arc CMS runs in its own named database, read the record from that database. Sign-in from an outside client is in Set up sign-in.