Who can edit what: owners, group owners and claim-on-write
Locks stop two people from editing at the same time. Ownership decides who may edit at all. Here is how I designed multi-owner configurators with team ownership, an atomic claim-on-write rule, fail-closed defaults, and a kill switch that turns enforcement off with a restart instead of a release.
The problem
Anyone could edit any configurator, even ones they had nothing to do with, and adding a single "owner" column didn't fit how teams really work.
The result
Configurators now have user and group owners, the first editor claims a free configurator atomically, and the same ownership rule protects the HTTP API and the WebSocket locks.
Stack
The problem
Edit locks solved "two people editing at the same time". But a different question remained:
Should this person be editing this configurator at all?
In the beginning, every logged-in user could change every configurator. As the number of products and people grew, that became risky: someone fixing their own product could accidentally change a colleague's, and nobody knew who was responsible for what.
We needed ownership: each configurator belongs to someone, and only its owners can change it. But the real organization was messier than "one product, one owner":
- some configurators are maintained by two or three people,
- some are maintained by a whole team, whose members change over time,
- many existing configurators had no owner at all, and assigning thousands of them by hand was not realistic,
- admins must still be able to fix anything.
The details
Version 1: a single column
The first version added one nullable column to the configurator table:
configurators.owner_id INT NULL -- null = free
Simple, but too limited. One owner per configurator didn't match how teams work, and changing owners meant editing that one column. It was a good first step and became the starting point for a migration.
Version 2: owner rows, then group owners
The model became lists:
configurator_owners (configurator_id, user_id): one row per person allowed to edit. Unique on the pair.groupsandgroup_members: teams of users, managed inside this application.configurator_owner_groups (configurator_id, group_id): a team owning a configurator. Every member may edit it.
The user ids come from the central auth service. There is deliberately no foreign key to a local user table: the user's identity lives in another service. (Keeping a local copy of those users for names and emails is its own story, covered in another post.)
Claim-on-write: free configurators get owners naturally
To avoid assigning thousands of existing configurators by hand, I used a simple rule:
A configurator with no owners is free. The first person who writes to it becomes its owner.
Ownership appears organically, exactly where people actually work. Opening or viewing a configurator never claims it. Only a real write does.
One rule, two doors
Users change configurators through two doors:
- the HTTP API (save an item, edit a filter…),
- the WebSocket lock events (start editing a page).
If the two doors used different ownership logic, sooner or later they would disagree. A user would be allowed to lock a page they can't save, or the reverse. So the decision lives in one function, used by both:
/** The one ownership rule, shared by the HTTP guard and the socket. */
export function isOwnedByOther(access: Access | undefined, userId: number): boolean {
return !!access && access.hasOwners && !access.userIds.includes(userId);
}
Where access is computed per configurator as:
interface Access {
hasOwners: boolean; // any user owner OR group owner at all
userIds: number[]; // direct owners + every member of every owner group
}
The challenges
Two racing claims
Claim-on-write has a classic race: two users save to the same free configurator at the same moment. Both read "no owners", both insert themselves, and now the configurator has two accidental owners, or one claim silently overwrote the other.
"Empty group" must not mean "free"
Imagine a configurator owned only by a team, and that team currently has no members. If we computed "who may edit" as an empty list and treated empty as free, the first passer-by would claim it. A team-owned product would be taken over just because the team was being reorganized.
Every route speaks a different id
Writes don't always carry a configurator id. Routes receive an item id, an attribute value id, a translation id, a filter id, a dependency id, a condition id, an operation id, a comparison id, a variable id… Each must be traced up the tree to the configurator it belongs to, efficiently, even for batch requests with many ids.
Some data has no owner at all
A few shared, global things (for example descriptor values used across products) also have conditions. They don't belong to any configurator, and must not be blocked by ownership.
Turning it on safely
Ownership touches every write route. If one resolver was wrong, enforcing ownership could suddenly block legitimate work across the whole company. I needed a way to switch it off in minutes, not hours.
The solution
1. An atomic claim with SELECT … FOR UPDATE
The claim runs in a transaction and locks the configurator row before checking:
async claim(configuratorIds: number[], userId: number) {
const claimed: number[] = [];
await db.$transaction(async (tx) => {
for (const id of configuratorIds) {
// Lock this configurator row until the transaction ends
await tx.$queryRaw`SELECT id FROM configurators WHERE id = ${id} FOR UPDATE`;
const userOwners = await tx.configuratorOwner.count({ where: { configuratorId: id } });
const groupOwners = await tx.configuratorOwnerGroup.count({ where: { configuratorId: id } });
if (userOwners + groupOwners > 0) continue; // already taken
await tx.configuratorOwner.create({ data: { configuratorId: id, userId } });
claimed.push(id);
}
});
return claimed;
}
If two claims race, the second one waits on the row lock until the first commits, then re-checks, sees an owner, and does nothing. A configurator gets exactly one claimer, never two, and never a partial state.
Note that a group owner counts as an owner here: a group never claims anything itself, but its presence makes the configurator taken.
2. Fail closed: hasOwners and userIds are separate on purpose
// Owned only by an empty group:
{ hasOwners: true, userIds: [] } // → locked for everyone (except admins)
// Truly free:
{ hasOwners: false, userIds: [] } // → claim-on-write applies
Keeping the two fields apart makes the empty-group case impossible to confuse with a free configurator. When in doubt, the system refuses (fail closed) instead of allowing (fail open).
3. Resolve any id to its configurator in one query
Each id type has a small resolver. Prisma compiles nested selects into one SQL query with joins, whatever the number of ids:
// translation → attribute value → item → configurator
const rows = await db.translation.findMany({
where: { id: { in: ids } },
select: { attributeValue: { select: { item: { select: { configuratorId: true } } } } },
});
return unique(rows.map((r) => r.attributeValue.item?.configuratorId ?? null));
Global data resolves to no configurator, which means "nothing to check" instead of an error. For example, a condition may belong to a filter, to a dependency, or to a global descriptor. The first two are owned; the last isn't:
r.filter?.item.configuratorId ?? r.dependency?.item.configuratorId ?? null
4. A guard on every write route
Routes declare which ids to check with a decorator. A global guard does the rest:
@Patch('filters/:id')
@RequireOwnership({ type: 'filter', ids: (req) => [Number(req.params.id)] })
updateFilter(...) { … }
Inside the guard, the order of checks is deliberate:
- Kill switch off? Allow everything (more on this below).
- No decorator? Allow (reads stay untouched).
- Admin? Allow, and never claim. Admins fix things; they shouldn't become owners of everything they touch.
- Extract ids:
nullmeans "this request legitimately has nothing to check", so skip.[]means the request shape changed and the extractor is out of sync, so reject with400. An empty list is a bug signal, never a silent pass.
- Resolve ids → configurators, load access for all of them in two queries (user owners and group owners), then check
isOwnedByOther. - Free configurators → claim them atomically, then allow.
5. The same rule on the WebSocket side
Before granting an edit lock over the socket, the gateway runs the same resolver and the same isOwnedByOther rule, but read-only: opening a page is not a write, so it never claims. If the user isn't an owner, they get a clear message:
"This configurator belongs to another user. Ask an admin to add you as an owner."
The presence feature uses the same answer to compute canEdit, so viewing and editing always agree.
6. A kill switch: restart, not release
All enforcement is behind one environment flag. Unless it is exactly "true", the guard and the socket check are no-ops. If a missed resolver starts refusing legitimate requests, we flip the flag and restart. That takes minutes, without a code release or a rollback under pressure. It made shipping such a cross-cutting feature much less scary.
7. Admin tools that fail loud
- Adding owners is idempotent: adding someone who is already an owner is silently skipped.
- Removing owners is all-or-nothing: if any requested user isn't actually an owner, nothing is removed, and the response lists the ids that were missing. A typo can't produce a half-applied change.
- Owners who no longer have access to the application stay listed, marked "no access", instead of disappearing. Their row is harmless, and admins can see and clean it up.
Lessons learned
- Ownership and locking are different problems. Locks answer "who is editing right now"; ownership answers "who may edit at all". Build both, separately.
- Model teams, not just people. Real responsibility is shared; group owners matched the organization instead of fighting it.
- Claim-on-write migrates data for you. Owners appear where work actually happens, with no big manual assignment.
- Make claims atomic.
SELECT … FOR UPDATEinside a transaction turns a race into a queue. - Fail closed, and make it explicit in the types. Separating
hasOwnersfromuserIdsmade the dangerous case impossible to misread. - One rule, every door. Share the decision function between HTTP and WebSocket so they can never disagree.
- Treat "empty" and "nothing" differently.
null= nothing to check;[]= something is broken. - Ship risky enforcement behind a kill switch. Being able to turn it off with a restart is what makes turning it on safe.
FAQ
What is claim-on-write?
An ownership rule where a resource without an owner is free, and the first user who modifies it automatically becomes its owner. It lets ownership spread naturally without assigning existing data by hand.
How do you prevent two users from claiming the same resource?
Do the check and the insert inside one transaction, and lock the resource row first with SELECT … FOR UPDATE. A concurrent claim waits for the lock, then sees the new owner and stops.
What does fail closed mean in access control?
When the system can't be sure a user is allowed, it denies access instead of allowing it. For example, a resource owned by an empty team stays locked rather than becoming free.
Should WebSocket events check permissions too?
Yes. WebSocket messages are another way into your system. Use the same authorization logic as your HTTP API so both entry points always make the same decision.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk