Why I modeled file permissions in Neo4j instead of SQL tables
On a meeting platform, file access ran through meetings, documents, versions and signatures. SQL needs a join per step, and in Neo4j the check walks the path.
In 2023 I spent eight months on a white-label meeting platform for healthcare and finance, in a team of five, working through an agency. The data model was my decision, and it is the part of that product I'd defend hardest.
The platform ran remote meetings, but the video mattered less than what remained after the call: who attended, what was said, which documents were in play, who was allowed to see them and who signed. In healthcare and finance, customers can get a video call from many vendors, so what they pay for is the record of the meeting.
So the hard question was "may this person open this file" rather than "can this person join", and it was the file question that didn't fit in tables.
What "may this person open this file" depended on
On that platform, the answer depended on a chain:
- the person attended a meeting
- the meeting is linked to a document
- the document has versions
- a version can carry a signature
- permission can arrive directly, granted to the person on the document, or through the meeting, because the document was attached to a meeting they were in
One more rule breaks most schemas: a document can belong to several meetings at once, so it has no single parent. No meeting_id column can tell you where the file belongs, because it belongs to more than one meeting, and each of them may grant something different.
The permissions themselves were small and ordinary: read, write, download and delete. What made them hard was the path from the person to the file.
In SQL, every new kind of link is another join
You can write this relationally, with a join for each hop and a branch of a union for each way permission can arrive. Simplified, it looks like this:
1SELECT EXISTS (2 -- direct grant3 SELECT 14 FROM document_grants g5 WHERE g.person_id = $16 AND g.document_id = $27 AND $3 = ANY (g.actions)8 9 UNION ALL10 11 -- grant through a meeting the person attended12 SELECT 113 FROM attendance a14 JOIN meeting_documents md ON md.meeting_id = a.meeting_id15 WHERE a.person_id = $116 AND md.document_id = $217 AND $3 = ANY (md.actions)18) AS allowed;That works for two paths. Add versions, and the question is about a specific version rather than the document. Add signatures, and some actions depend on whether a version has been signed and by whom. A meeting that references another meeting's documents adds yet another path. Each new kind of relationship means another join and another branch of the union, and sooner or later a recursive CTE, because some chains don't have a fixed length.
Two things get worse at once. The check slows down with every new type of relationship, because the database rebuilds the chain from rows every time it runs. The code also gets harder to maintain with every new rule, because the policy exists only as the shape of a query, and the next person has to reverse-engineer it from the joins. Neither problem is visible in the first month. They show up once the product starts selling.
Modeling the domain as a graph in Neo4j
So I modeled the domain as a graph in Neo4j. People, meetings, documents, versions and signatures are nodes, and attendance, attachment and permissions are edges.
The permission question is a path question, and following a path is the native operation of a graph database. The query walks the chain instead of rebuilding it from rows on every check.
Nor is it an exotic choice. Google models access control the same way, for the same reason: when access is defined by relationships between things, it makes sense to store those relationships directly.
Here is a simplified version of the model, with names chosen for this post:
1(:Person)-[:ATTENDED]->(:Meeting)2(:Meeting)-[:ATTACHED {actions}]->(:Document)3(:Person)-[:CAN {actions}]->(:Document)4(:Document)-[:HAS_VERSION]->(:Version)5(:Person)-[:SIGNED]->(:Version)The permission check from the SQL above, written against this model:
1MATCH (p:Person {id: $personId}), (d:Document {id: $documentId})2RETURN3 EXISTS {4 MATCH (p)-[g:CAN]->(d)5 WHERE $action IN g.actions6 }7 OR EXISTS {8 MATCH (p)-[:ATTENDED]->(:Meeting)-[a:ATTACHED]->(d)9 WHERE $action IN a.actions10 } AS allowedBesides being shorter, the query reads like the rule: "the person has a direct grant, or attended a meeting that attached this document with that action." Someone new to the code can read the policy straight off the query.
Adding a hop doesn't change the kind of work. Asking about a version instead of a document adds one more step along an edge:
1MATCH (p:Person {id: $personId})-[:ATTENDED]->(:Meeting)2 -[a:ATTACHED]->(:Document)-[:HAS_VERSION]->(v:Version {id: $versionId})3WHERE $action IN a.actions4RETURN count(v) > 0 AS allowedThat was the property I was buying: permission questions answered by traversal, however long the chain gets.
Documents attached while the meeting was running
If the graph had been filled in before each meeting and only read during it, a well-indexed relational model might have been fine. It was not filled in beforehand.
A document could be uploaded in advance or attached while the meeting was running. The moment it was attached, it had to carry the right permissions for everyone present and appear for them immediately. The graph changed live, while people were watching.
In the graph, attaching a document is one edge:
1MATCH (m:Meeting {id: $meetingId}), (d:Document {id: $documentId})2MERGE (m)-[a:ATTACHED]->(d)3SET a.actions = $actions,4 a.attachedAt = datetime()No individual permissions are written. Everyone who attended the meeting can now reach the document, because their access is derived from the path rather than copied onto each of them. There is no fan-out of one row per attendee, so nothing can end up half-written, with three people seeing the file and the fourth not yet.
More generally, copied permissions make every change to the structure a synchronization problem, while derived permissions change along with the structure. The graph made derived permission cheap enough to be the default.
The same model handled the multi-meeting rule without special cases. A document attached to three meetings has three ATTACHED edges, each with its own actions. There is no owner to pick and no primary meeting to agree on, and version history and signing stay with the document however many meetings point to it.
Transcripts, whiteboard, guest access and the browser extension
The graph was the core, but it was worth having only because the rest of the platform also treated the meeting as a record. Each of these could be its own post, so I'll keep them short.
Transcription ran in real time, with speakers separated, and the finished transcript fed into the agenda of the next meeting, which made it part of the process.
The whiteboard was kept as objects rather than as a picture. Every stroke, shape and piece of text stayed a separate element with its own properties, so after the meeting the board could be reloaded and edited again.
Guest access was there because of HIPAA: a patient shouldn't have to create an account to attend a consultation. Here a compliance requirement added a feature to the product, and a graph is a comfortable place to express "this person is here, for this meeting, and can reach exactly what this meeting grants."
The browser extension was optional and used for starting and reviewing meetings. It asked for one permission and had no media access at all. An extension for a meeting product naturally pulls toward broad permissions. In a product sold into healthcare, broad permissions mean a harder store review, a warning at install and a question in a customer's security review with no good answer. The product was also sold to an American bank.
When I wouldn't reach for a graph
Not every permission system needs a graph. If access is decided by a role and a tenant, a row and a filter are simpler, faster and easier to hire for. A graph is worth it when all three of these are true:
- permission arrives through relationships, not only through roles
- the chains vary in length or keep gaining new kinds of links
- the structure changes while people are using it, so copied permissions would need constant synchronization
That platform had all three. Most CRUD applications have none of them, and for those a well-designed table is the right answer.
What I'd do differently
The data model held up. The front end didn't, and no single decision caused that.
For eight months the team built features across the product, and a shared component layer never emerged. By the end the codebase had three UI frameworks and four dropdown libraries. No one chose that. It accumulated one reasonable local decision at a time, because there was no shared library to reach for first.
On the next project I built the shared library first, before the features that would otherwise each have brought their own.
Writing
If you’re working on something similar, send us the details.
The first call takes 30 minutes, and you’re under no obligation.