System design interview
Design a file sharing permissions service
Relation tuples, inheritance, cached checks, revocation tokens.
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- The app asks whether Bob can edit file f9. It adds the token t812 that it saved when f9 was last moved or shared.
- The API looks for a cached result for this question, computed at a snapshot no older than t812. It finds none.
- It reads f9's tuples at that snapshot: its direct editors, and its parent folder, then the folder's editors and its own parent.
- The tuples come back. A folder grants edit to the group legal-team, and Bob is in the group through a nested group.
- The API stores the sub-results, such as Bob's groups and the folder's editors, each with its snapshot, so the next file in this folder reuses them.
- It answers allowed, and says which snapshot it used.
- Drive app
- Access API: check and share
- Check cache: by snapshot
- Tuple store: versioned tuples
- Reverse index: shared with me
- Change log
- Indexer: builds the index
On this page
In 2 minutes
I would store every fact as a relation tuple, such as the parent of a file, a share to a group, or a membership, and compute rights with a small schema per type. Editors can view because the view rule includes the edit rule, and a share on a folder covers everything below it, so sharing a folder with 2 million files is one write. A check walks up from the object, reading branches in parallel, so it costs the depth of the walk, about 4 hops or 8 ms, not the number of files. At 1,000,000 checks a second, a cache of sub-results hit 95% of the time leaves about 200,000 tuple reads a second.
Entries carry the snapshot they were computed at, so no one invalidates them. Group membership is flattened into an index, with a live walk as the fallback. For revocation, every write returns a consistency token, the commit time. The app stores it with the object it changed, and a check for that object must read at least that new.
So a user removed from a folder at 900 cannot open a file added at 905. Normal revocations apply within about 10 seconds. Shared with me is a reverse index built from the change log, keyed by group so a share to 5,000 people is one row, and each candidate is re-checked before display. The trade-off is that apps must save and pass tokens.
Step 1 of 6: Clarify the scope
Interviewer asks
Design the permissions service of a cloud drive. Files live in folders, people belong to groups, and a file or folder can be shared with a person, a group, or anyone with a link. For any file, answer quickly: can this user view it, edit it or share it? Also list everything shared with a user, and never show a file to someone whose access was just removed.
Questions to ask first
Ask these before you draw anything. Under each one is why its answer changes the design.
- Who can have access: single people, groups, nested groups, and anyone with a link?
Each kind of subject adds a way for access to arrive. Nested groups need membership to be followed through several levels.
- Does a folder's sharing apply to everything inside it, to any depth?
Inheritance means one share on a folder covers millions of files, so we must not copy permissions onto each of them.
- How many checks a second, and what is the latency budget?
A check runs on every listing and every open. A budget of a few tens of milliseconds rules out long walks on each call.
- After access is removed, how soon must the person lose it?
Seconds of lag is usually fine for old files. For a file added right after a removal, the answer must be exact.
- Do we need a view of everything shared with me, and who has access to this file?
Both reverse the direction of a check, and neither can be answered by looking at one file.
Requirements
Functional
- Check whether a user has the view, edit or share right on a file or folder.
- Grant and revoke access for a person, a group or a link, on a file or a folder.
- Inherit access from parent folders and from nested groups, with rules such as editors can also view.
- List the files shared with a user, a page at a time.
Non-functional
- A check answers in under 20 ms at p99, and in under 1 ms when cached.
- A user removed from a folder never sees a file added after the removal, even for a moment.
- For other files, a revocation applies within 10 seconds.
- The service handles 1,000,000 checks a second at the peak, and a folder with 2 million files costs the same as a small one.
- 99.99% availability, because every file open depends on it.
Step 2 of 6: Size it
Checks are many and writes are few, so cache sub-results and share them across files. Never copy permissions down a tree. Instead, let each check walk up, and make freshness a property of the request, not of the cache.
Assumed for the exercise, not facts about a real system:
- 5 billion files and folders hold about 3 relation tuples each, such as a parent link and a share. There are 1 billion group membership tuples. A tuple is about 100 bytes.
- 1,000,000 checks a second at the peak. A cache answers 95% of them. A cold check follows up to 4 dependent hops, each a 2 ms read.
- The tuple store can take about 10,000 writes a second. A user belongs to about 20 groups, and a group has up to 5,000 members.
- Tuple store size
- (5B × 3) + 1B = 16B tuples × 100 B
- ≈ 1.6 TB, big but shardable
- Cold evaluations and reads
- 1,000,000 × 5% = 50,000 evaluations/s; × 4 reads
- ≈ 200,000 tuple reads/s
- Latency of a cold check
- 4 dependent hops × 2 ms, with branches read in parallel
- ≈ 8 ms, inside the 20 ms budget; a hit takes about 0.3 ms
- Share a folder with 2 million files
- By inheritance: 1 tuple. By copying: 2,000,000 tuples ÷ 10,000 writes/s
- 1 write, against 200 seconds of writes (and again to revoke)
- Share a file with a group of 5,000
- Per-user rows: 5,000 index rows. By group: 1 row, and 1 + 20 range reads when a user lists
- 5,000 writes against 1 write and 21 reads
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
The Drive app calls the Access API whenever it opens a file or builds a listing. The API holds no state of its own. It evaluates a check by reading relation tuples from the Tuple store and combining them under the rules for the object's type. Before it reads, it looks in the Check cache for sub-results such as whether this user is in that group, or an editor of that folder.
A write, such as sharing a folder or moving a file, is one transaction on the tuple store. It returns a consistency token, which is the commit time. The app saves the token with the content it changed, and passes it on later checks to say: look at the world at least as new as this.
Each committed write is appended to a Change log. An Indexer reads the log in order and builds the Reverse index, which answers what is shared with a user. The index is built after the fact, so it can lag. The API treats its entries as hints and re-checks each one before it shows it.
Other paths through the system
Revoke, then add a file
- An admin removes Mia from the viewers of the legal folder.
- The API deletes that tuple in one transaction on the tuple store.
- The commit returns its time, t900, which becomes the consistency token for this change.
- The app saves t900 with the folder. A sensitive file added to the folder a moment later is saved with a token at least as new.
- Mia opens the new file. The app sends the check with that token.
- A cached result says Mia may view the folder, but it was computed at t850, before the removal. It is older than the token, so it is not used.
- The API reads at t900 or later, where Mia's tuple is gone, and finds no path to access.
- It answers denied.
Share and list shared files
- A share on file f9 is committed. The tuple store appends it to the change log, in commit order.
- The indexer reads the next entries from the log.
- It adds an entry under the share's subject, which is the group legal-team and not each of its members.
- Later, Mia opens Shared with me. The API reads the entries for Mia and her groups, 21 ranges in all, and gets a page of candidates.
- It re-checks each candidate against the tuple store, because the index is only a hint, and drops any that no longer qualify.
- The app gets a page of files that Mia can really open now, with a cursor for the next page.
Step 4 of 6: Go deep on the hard part
Relation tuples, inheritance and rules
Interviewer asks
How do you model files, folders, groups and links so that sharing and inheritance are cheap?
Store every fact as a tuple: an object, a relation, and a subject.
Read moreShow less: Relation tuples, inheritance and rules
The file f9 has the parent folder legal. The folder legal has the viewer group legal-team. Bob is a member of the group backend, and backend is a member of legal-team. A subject can be a single user, the members of a group, or a special subject for links. Everything is the same shape, which makes storage, caching and sharding simple.
Rules instead of copies. For each type, a small schema says how a right is computed. For a file: a user can view it if they are a direct viewer, or can edit it, or can view its parent folder. A user can edit it if they are a direct editor, or an owner, or can edit the parent folder. So editors can view because the view rule includes the edit rule, with no extra tuples stored. This is how one share on a folder covers every file below it, to any depth.
Groups. A group's members can include other groups. A check follows membership through the levels, keeps a visited set so an accidental loop cannot run forever, and stops at a depth limit, about 20. Reaching the limit counts as denied and raises an alert.
Links. Sharing by link creates a hard-to-guess secret, and a tuple that gives a viewer relation to the subject holder of that secret. A request that carries the secret is checked as that subject. Revoking the link deletes the tuple. Public access is a special subject that stands for everyone.
Evaluating. A check starts at the object and the right, and expands each branch of the rule into a lookup, run in parallel. It returns allowed as soon as one branch succeeds. The latency is the depth of the walk, not the number of branches: 4 hops of 2 ms is about 8 ms. Changes to the schema are versioned, and every check says which version it used.
The alternative, an access list on each file, breaks down at once. Sharing a folder would mean writing the list of every file below it, and revoking would mean writing them all again.
Fast checks at 1,000,000 a second
Interviewer asks
How do you keep checks fast, including for groups of thousands and folders with millions of files?
Cache sub-results, not only final answers. The question can Bob edit file f9 is asked once.
Read moreShow less: Fast checks at 1,000,000 a second
The question is Bob an editor of the legal folder is asked for every file in it. Cache the sub-result, keyed by object, right, subject and the snapshot it was computed at. A folder with 2 million files then has a few shared entries, so the cost of a check does not grow with the folder. With 95% hits, 1,000,000 checks a second become 50,000 evaluations and about 200,000 tuple reads.
Snapshots, not invalidation. Each cache entry says which snapshot it reflects, and it can serve any request that needs a snapshot no newer than that. A request with no token accepts a recent snapshot, rounded to a few seconds ago, so many requests share entries. A request with a token needs an entry at least that new. A new write creates a newer snapshot, so no one has to hunt down and delete stale entries.
Flatten group membership. Following nested groups on every check is the slow part. Keep a flattened index that lists every group a user belongs to, directly or through nesting, usually about 20. Rebuild entries from the change log when a membership tuple changes. A check then asks whether any group on the object's access path is in the user's set. The index records the snapshot it reflects. If it lags behind what a request needs, fall back to walking the live tuples for that request.
Protect the store. Many callers may ask for one popular folder's rights at once, so allow only one fetch per key and let the rest wait for it. Give each check a deadline and send a second read to another replica if the first is slow, at about 5 ms. Limit depth and fan-out, and apply per-client quotas.
Batches. A folder listing needs checks for 50 children. Accept a batch, and share sub-results inside it: the 50 files have the same parent, so the folder's rights are computed once.
Moves. Moving a folder changes one parent tuple. Everything below it picks up its new access at once, with no rewriting. The cost is that a move makes cached results for that subtree stale, which the new snapshot handles.
Revocation, the new enemy problem and listing
Interviewer asks
A user is removed from a folder, and a sensitive file is added right after. How do you make sure they cannot open it, and how do you list what is shared with a user?
The problem. At time 900 an admin removes Mia from a folder.
Read moreShow less: Revocation, the new enemy problem and listing
At time 905 someone adds a confidential file to it. If a check for the new file reads from a replica or a cache that is still at time 850, it sees Mia as a viewer, and she can open a file that was never meant for her. Access that is merely a few seconds late is usually fine. Reading an old answer for new content is not.
Consistency tokens. The tuple store gives every write a commit time from one ordered clock, and it can read as of any recent time. Each write returns its commit time as a token. The app saves it with the object the write changed. When the file is created inside the folder, the write that records its parent returns time 905, and the app stores that with the file. Every later check for it says: evaluate at a snapshot at least as new as 905. The 900 removal is before 905, so it is included, and Mia is denied.
Enforcing the token. A replica knows the latest time it has applied. If it is behind the requested time, it waits a few milliseconds, or the read goes to a fresher replica. Cache entries computed before the token are ignored. Only requests with a token pay for freshness. Others read a snapshot from a few seconds ago, which bounds the lag for a normal revocation at about 10 seconds.
Shared with me. This is the check turned around. Scanning every file for every user is impossible, so keep a reverse index: a row for each share, keyed by the subject. Each write goes to the change log, an indexer reads it in order, and adds or removes rows. Index a group share under the group, not under each of its 5,000 members. A user's listing reads their own ranges plus one for each of their roughly 20 groups, 21 reads, and merges them.
Never trust the index. It is built after the fact, so it can lag, and a lagging entry might show a file that was just revoked. So the API re-checks each candidate at the request's snapshot before returning the page, and drops the ones that fail. The worst effect of lag is that a brand new share shows up a few seconds late. If the caller passes a token from its own share, the API waits for the indexer to reach that time, or merges the new tuple in by hand, so people see what they just did.
Step 5 of 6: Name the trade-offs
Storing permissions
Relation tuples with inheritance rules over An access list copied onto every file
Sharing or moving a folder is one write, however big it is. The cost is a walk on each check, which caching has to hide.
Freshness of reads
Recent snapshots, plus tokens for sensitive objects over Always read the latest committed data
Most checks are served from cache and replicas, and only the requests that need it pay for freshness. The cost is that apps must save and pass tokens.
Nested group membership
A flattened index that falls back to a live walk over Walk the nested groups on every check
A check reads one set, not many levels. The cost is an index to maintain, and a lag that the fallback has to cover.
Listing what is shared with me
A reverse index, checked again before display over Scan files and check each one at request time
Listing reads about 21 ranges, not billions of files. The cost is an index that lags, so new shares can appear late.
Step 6 of 6: Say what breaks next
Questions an interviewer asks once the design is on the board. Answer each one out loud first.
- How would you add a block rule, such as shared with everyone except Dan?
Add an exclusion to the rule: a user can view if they have a viewer path and are not in the blocked relation. Evaluate it at check time, and do not bake it into flattened sets. It makes caching a little weaker, since a result depends on two sub-results.
- How do you answer who has access to this file?
Expand the rule in the other direction. Walk up the parents, collect the subjects on each level, and expand groups into members. Do it in the background and cap the size for huge groups. It is an admin report, so it can take a second or two.
- How would you move from old per-file lists to this model?
Write to both for a while, and run the new check in shadow mode beside the old one, logging every difference. Fix the differences, then switch reads over by percentage. Keep the old data for a rollback period.
- How would you shard the tuple store?
Shard by object id, so a file's tuples and its parent link sit together, and keep a second copy keyed by subject for the reverse lookups. Very popular objects, such as a company-wide group, are copied to more replicas and cached in memory. Walks that cross shards run in parallel.