Designing Simple Policy-Based Access Control
A small LLD for deciding who can access an S3 object
Imagine that Alice asks our storage service to read reports/public/a.txt. How does the service decide whether to say yes?
We will design a small policy-based access control (PBAC) system to answer that question. We will start with the data, identify the classes and methods, and then put them together in pseudocode. No particular programming language is required.
The problem
Our authorization service receives a request containing:
- Who: a principal, such as Alice.
- What: an action, such as
s3:GetObject. - Which resource: a bucket or object, such as
reports/public/a.txt. - Context: request facts, such as whether the connection uses HTTPS.
It returns Allow or Deny, with a reason. We assume another part of the system has already checked Alice's credentials and given us a trustworthy principal.
For this simple version, permissions come from two places: a policy attached to the caller and a policy attached to the bucket. A policy is a list of statements. Each statement says Allow or Deny for a particular action and resource. A bucket-policy statement can also name the principal it applies to.
There are three rules to remember:
- A matching explicit Deny wins, even if another statement says Allow.
- If the required Allow is missing, the request is implicitly denied.
- For a bucket in the caller's account, either the identity policy or bucket policy can allow access. Across accounts, both must allow access.
Core entities
An account is a tenant. Account 111 owns the reports bucket. Alice belongs to account 111; Bob belongs to account 222. Alice's request is same-account, while Bob's request to reports is cross-account.
Account
id
name
Principal
id
accountId
Bucket
name
ownerAccountId
AuthorizationRequest
principal
action
resource
bucketName
context (for example, secureTransport)
Policy
statements[]
Statement
effect // ALLOW or DENY
actionPattern // for example, s3:GetObject
resourcePattern // for example, reports/public/*
principal // optional; used in bucket policies
condition // optional; for example, secureTransport = false
Notice that Account and Principal answer different questions. The account tells us which tenant owns an identity or bucket. The principal tells us who is making this request.
Classes and methods
We need one class to fetch data, one to evaluate a policy, and one to make the final decision.
PolicyRepository
getIdentityPolicy(principal) -> Policy
getBucketPolicy(bucketName) -> Policy
BucketRepository
getBucket(bucketName) -> Bucket
PolicyEvaluator
evaluate(policy, request, isBucketPolicy) -> ALLOW | DENY | NO_MATCH
statementMatches(statement, request, isBucketPolicy) -> true | false
AuthorizationService
authorize(request) -> Decision
PolicyEvaluator looks at one policy. AuthorizationService combines its results. This keeps the matching rules separate from the same-account and cross-account rules.
Step 1: Evaluate one policy
A statement matches only when its action, resource, principal when needed, and condition all match the request.
function statementMatches(statement, request, isBucketPolicy):
if isBucketPolicy and statement.principal does not match request.principal:
return false
if statement.actionPattern does not match request.action:
return false
if statement.resourcePattern does not match request.resource:
return false
if statement.condition does not match request.context:
return false
return true
For our example, reports/public/* means any object whose resource starts with reports/public/. A missing condition means there is no extra restriction.
Now we can evaluate the whole policy:
function evaluate(policy, request, isBucketPolicy):
foundAllow = false
for each statement in policy.statements:
if not statementMatches(statement, request, isBucketPolicy):
continue
if statement.effect == DENY:
return DENY
if statement.effect == ALLOW:
foundAllow = true
if foundAllow:
return ALLOW
return NO_MATCH
NO_MATCH is important: it means this policy did not grant the request. It is different from a statement that explicitly says DENY.
Step 2: Combine the policies
First, load the bucket and both policies. Then apply deny precedence and the account rule.
function authorize(request):
bucket = bucketRepository.getBucket(request.bucketName)
identityPolicy = policyRepository.getIdentityPolicy(request.principal)
bucketPolicy = policyRepository.getBucketPolicy(bucket.name)
identityResult = evaluator.evaluate(identityPolicy, request, false)
bucketResult = evaluator.evaluate(bucketPolicy, request, true)
if identityResult == DENY or bucketResult == DENY:
return Decision(DENY, "explicit deny")
sameAccount = request.principal.accountId == bucket.ownerAccountId
if sameAccount:
if identityResult == ALLOW or bucketResult == ALLOW:
return Decision(ALLOW, "same-account allow")
else:
if identityResult == ALLOW and bucketResult == ALLOW:
return Decision(ALLOW, "cross-account allow")
return Decision(DENY, "implicit deny")
That is the entire decision engine for our small design!
Let's try some requests
Give Alice an identity-policy statement allowing s3:GetObject on reports/*. Give the bucket two statements: deny object reads over HTTP, and allow Bob to read reports/public/*. Bob's own identity policy also allows him to read reports/public/*.
| Request | Identity result | Bucket result | Final result |
|---|---|---|---|
Alice reads public/a.txt over HTTPS |
Allow | No match | Allow: same account |
Alice reads public/a.txt over HTTP |
Allow | Deny | Deny: explicit deny |
Alice uploads public/a.txt |
No match | No match | Deny: implicit deny |
Bob reads public/a.txt over HTTPS |
Allow | Allow | Allow: cross-account |
Bob reads other/a.txt over HTTPS |
No match | No match | Deny: implicit deny |
The HTTP row illustrates explicit deny: the bucket policy actively blocks that request. The upload row illustrates implicit deny: nobody granted s3:PutObject.